Skip to content

Capell coding standards

These standards describe the shapes already used by the strongest and most common code in the Capell host monorepo. They apply to packages/, repository-level tests/, and supporting scripts. Package-specific conventions may add constraints, but they must not weaken these rules.

The priority order is:

  1. Preserve observable behaviour and public compatibility.
  2. Preserve package and public-render boundaries.
  3. Make invalid states explicit through types and focused validation.
  4. Match the established local shape.
  5. Prefer mechanical enforcement over review-only guidance.

A consistency change must not rename a public class, route, translation key, config key, Blade component, event, command, or persisted field unless the change is explicitly treated as a compatibility migration. A standards pass is not permission to alter those contracts.

The July 2026 standards inventory covered the following surfaces before rules were selected:

SurfaceRepository populationRepresentative sampleDominant findings
PHP under packages/, tests/, and scripts/3,451 filesrepository-wide check100% use declare(strict_types=1)
Actions405 classes24 across all host packagesverb-led operations, handle(), explicit returns, explicit AsObject + AsFake traits
Support code373 files25 across all host packagesfocused collaborators, typed APIs, early returns
Enums149 enums20 across all host packagessingular concepts; suffix usage is split and therefore compatibility-owned
Livewire components9 classesall 9typed public state and authorization at mutations
Blade views189 views21 across public, admin, installer, and marketplace viewskebab-case component paths, pre-hydrated public data, Tailwind-first markup
Migrations49 files24 across all host packagesanonymous classes, typed up()/down(), snake-case schema names
Pest tests1,180 PHP test/support files30 across all host packages and root testsit('...'), factories/helpers, deterministic setup

Not every majority is safe to enforce retroactively. Only about 64% of Actions are final, and enum names are split between semantic names and an Enum suffix. New code follows the defaults below, while existing extension and compatibility surfaces remain unchanged until a dedicated migration is justified.

Every PHP file starts with declare(strict_types=1);. Pint enforces this repository-wide. Do not add a local exception.

Use native parameter, property, constant, and return types wherever PHP can express the contract. Use PHPDoc to add information PHP cannot express, especially collection key/value types and array shapes; do not duplicate a native type with a less precise docblock.

packages/frontend/src/Actions/AssertPublicRenderPerformanceBudgetAction.php is the reference shape for a small typed Action. packages/frontend/src/Data/Assets/FrontendResourceData.php is the reference for a structured Data boundary.

Promote constructor dependencies and immutable Data properties when the promoted declaration is clearer than separate property boilerplate:

public function __construct(
private readonly RuntimeSchemaState $schemaState,
) {}

This is the shape used by packages/admin/src/Actions/Reports/BuildPublicRenderSafetyReportAction.php.

Use readonly for injected dependencies and value-object state that must not be reassigned. Do not mark Eloquent models, Livewire components, Filament resources, or framework-hydrated properties readonly.

Concrete classes are final by default when Capell owns construction and subclassing is not a supported seam. Leave a class open only when one of these is true:

  • downstream packages are expected to subclass it;
  • Filament, Livewire, Eloquent, or a test double needs inheritance;
  • an abstract/template-method hierarchy deliberately owns the variation;
  • the class is already a public extension surface and sealing it would be a compatibility break.

Do not mass-apply final. Existing open classes are reviewed individually because the current repository is below the threshold for mechanical enforcement.

Use positional arguments when a call is short and the meaning is obvious. Use named arguments when a constructor or method has several same-typed parameters, optional flags, or a public structured boundary. Once one optional argument is skipped, name the remaining arguments.

packages/admin/src/Actions/Reports/BuildPublicRenderSafetyReportAction.php uses named arguments for report Data objects. packages/frontend/src/Data/Assets/FrontendResourceData.php uses positional arguments inside its tightly controlled named factories; callers should normally use those factories.

Never use named arguments as decoration on every one-argument call. Public parameter names are compatibility surface, so rename one only through an explicit compatibility review.

Prefer early returns for invalid, absent, unauthorized, or already-complete states. Keep the successful path at the lowest indentation level. A branch that represents a domain decision may remain explicit even when it could be compressed into a ternary.

BuildPublicRenderSafetyReportAction::handle() returns the missing-table result before running report queries. PublicViewQueryGuard::guard() returns immediately when the guard is disabled or the audience is not public.

Use an Action when the operation:

  • expresses a domain or application verb;
  • is called from more than one delivery mechanism;
  • needs direct behavioural tests, authorization, transaction ownership, queueing, or orchestration;
  • is a stable operation packages may call.

Actions use a verb-led singular name and the Action suffix, expose one public handle() operation, and compose AsObject with AsFake where static run() is part of the call shape. AsAction is prohibited because it adds controller, command, listener, and job adapters that a domain Action may not need. Add a granular adapter trait only when its corresponding entry point is actually used. Production and test callers invoke Actions through ::run(...), never by resolving, constructing, or injecting an Action solely to call handle(...). Constructor injection is preferred for collaborators, and tests fake downstream Actions with ::shouldRun(), ::shouldNotRun(), ::mock(), or ::spy(). Public lifecycle Actions that predate the suffix rule remain compatibility exceptions.

Use a Support class for a cohesive capability, policy, resolver, registry, adapter, or stateful collaborator with more than one meaningful operation. packages/frontend/src/Support/Render/PublicViewQueryGuard.php is a Support class because it owns query-capture state and several cohesive policies.

Keep logic as a private method when it is meaningful only to one class, has no independent boundary, and extracting it would expose implementation detail. Extract duplicated logic only after at least three real call sites or when a security/correctness boundary needs one owner.

  • PHPStan remains at the configured level; lowering the level or coverage threshold is not a fix.
  • No property, parameter, or return is left untyped unless a framework-owned signature prevents it. Add the narrowest truthful PHPDoc in that case.
  • Arrays use shapes for fixed records and list<T> or array<TKey, TValue> for collections.
  • Laravel and Eloquent collections declare both key and model/value types.
  • Generic interfaces are always supplied with their template types at use sites.
  • Data received from HTTP, config, cache, JSON, or a package boundary starts as mixed and is narrowed before domain use.
  • A PHPDoc assertion must describe a runtime fact established by adjacent validation; never silence PHPStan with a type that can be false at runtime.

packages/marketplace/src/Data/ExtensionListingData.php demonstrates typed immutable boundary data and typed collection properties. packages/admin/src/Filament/Resources/Pages/Tables/PagesTable.php demonstrates Builder<Model>, Eloquent collection, and list annotations.

The PHPStan ignore file is a debt ledger, not a destination:

  • never add a broad package/path ignore for a new error;
  • never increase an existing count to land a change;
  • prefer correcting the native type, generic, or local narrowing;
  • every inline @phpstan-ignore includes the error identifier and a same-line reason describing the framework or runtime fact PHPStan cannot see;
  • durable debt also links to an upstream issue or Capell decision document when one exists;
  • delete an ignore in the same commit that fixes its last matching error.
  • Actions are verbs: ResolveFrontendResourcePlanAction, AssertPublicRenderPerformanceBudgetAction, BuildPublicRenderSafetyReportAction.
  • Support classes are capabilities or roles: PublicViewQueryGuard, AdminSurfaceLookup, ThemePackageCandidates.
  • Enums name a singular concept. Do not add an Enum suffix to a new enum unless it extends an already suffix-based public family. Never rename an existing enum solely to change the suffix.
  • Events describe something that happened and use past tense, such as PagePublished rather than PublishPage.
  • Boolean properties and methods read as predicates: isPaid, hasTable(), canInstall(), shouldLogVisit().
  • Interfaces name the capability. Use Interface only where the established family already uses it; do not churn existing contract names.
  • Variables, parameters and closure arguments use full descriptive names everywhere, including tests, migrations and example snippets in docs: $query, not $q; fn ($query) => ...; Schema::table('pages', fn ($table) => ...). In prose examples write workspace or a concrete number, not W or N.

Three legacy Action classes intentionally do not have the suffix because their class names are already consumed: AssignPermissionsToRole, GetMaxUploadSizeInBytes, and BladeComponentFacadeResolver. New exceptions require an explicit compatibility rationale.

SurfaceCanonical formExample
Database tables and columnssnake_case, plural tables, _id foreign keyspublic_render_contract_events, page_id
Config namespacepackage name in kebab casecapell-frontend
Nested config keysdot-delimited path with snake_case segmentscapell-frontend.public_view_query_guard.enabled
Translation namespace/grouppackage namespace plus dot-delimited groupcapell-admin::reports.*
Translation leaf keysnake_casepublic_render_safety_metric_failures
Route namedot-delimited hierarchy; keep established package prefix and kebab-case compound segmentscapell-marketplace.install-flow.callback
Blade component/view pathdirectories and filenames in kebab casecomponents/page/neighbor-link.blade.php

These names are observable contracts. Existing mixed route prefixes and the legacy site_domain_item_label component are not renamed in a standards-only change.

Use a Form Request at a conventional HTTP controller boundary when request validation and authorization form one reusable contract. Filament and Livewire operations use their component/schema validation APIs, then pass typed or documented arrays into Actions. Domain invariants remain in the Action or domain object so console, queue, and programmatic callers cannot bypass them.

ValidatePageAuthoringAction is the extension-aware authoring boundary: UI layers collect form state, while tagged validators enforce package rules independently of the delivery mechanism.

Authorization occurs at the delivery boundary and again inside reusable mutating operations when they can be called independently. Never rely on a hidden button as authorization.

In a Spatie\LaravelData\Data class, a non-nullable array property (even with = []) gets an inferred required|array rule, and required rejects an empty array. validateAndCreate() therefore fails on a present-but-empty field while new Data(...) works. Override rules() and mark optional passthrough arrays ['sometimes', 'array']; avoid nullable, which causes null-to-array hydration errors.

Use a model scope for a reusable domain predicate or ordering, especially when it appears in multiple queries. Keep a one-off, local filter inline when naming it would not add domain meaning. Eager-load relationships used in loops or view-data assembly.

The scopes in packages/core/src/Models/Concerns/HasPublishDates.php are the reference for named visibility predicates. PagesTable composes scopes and typed query callbacks rather than duplicating visibility SQL.

No database query, relationship lazy load, authentication lookup, or service resolution belongs in a public Blade view. Prepare render data before calling the view.

Read environment variables only from config files. Runtime code reads config() or a typed settings/config collaborator. This is enforced by Pest architecture tests.

Prefer injected collaborators in domain Actions and Support classes when an interface or established service exists. Facades are acceptable at Laravel integration boundaries—service providers, console commands, Eloquent transactions, framework macros—and in narrowly scoped adapters. Do not replace a clear injected dependency with a facade for convenience.

Use the central helper instead of inlining the operation:

  • Capell\Core\Support\Json\JsonCodec::encode() / decodeArray() for JSON. encode() always throws on error; decodeArray() returns the supplied default on a parse failure or a non-array result.
  • Capell\Core\Support\Url\UrlPathNormalizer (stripIndexPhp(), joinPrefix(), stripPrefix()) for URL path manipulation in Actions and kernel steps.
  • Capell\Core\Support\Slug\SlugGenerator: slug() wraps str($value)->slug() for PHP, and slugifyState() builds the Filament/Alpine slugify snippet.

Back every fixed-set Select, Radio, CheckboxList or ToggleButtons with a backed enum that implements Filament\Support\Contracts\HasLabel and pass ->options(MyEnum::class). If no enum exists, create it first. An inline ->options([...]) array is for genuinely dynamic values only. One enum keeps the values, labels and translation keys in one place for casts, accessors and APIs.

Use CarbonImmutable for Data objects, policy decisions, timestamps passed between layers, and new immutable Eloquent casts. Respect an existing framework signature or mutable model contract rather than converting it opportunistically.

Publish, draft and scheduled state is derived by Capell\Core\Enums\PublishVisibilityStateEnum::fromDates() (precedence: deleted, expired, draft, scheduled, published), with the draft sentinel created and detected only through Capell\Core\Support\Publishing\PublishSentinel (draftValue(), isDraftValue()). Never write sentinel arithmetic such as now()->addYears(100) inline. HasPublishDates exposes publishVisibilityState(), scopeDraftSentinel() and scopeScheduled().

packages/marketplace/src/Data/MarketplaceInstallPolicyEvidenceData.php and MarketplaceInstallIntent are the reference immutable Data/cast shapes.

Public output is a security and performance boundary. Read docs/frontend/public-html-safety.md and docs/frontend/tailwind-vendor-css.md before changing it.

Use Tailwind utilities in Blade for layout, spacing, typography, colour, borders, responsive states, focus states, and other utility-shaped presentation. Let the configured Prettier/Tailwind tooling order classes; do not alphabetize them by hand.

Project-authored CSS is limited to these established homes:

  • packages/admin/resources/css/capell-admin.css for Filament/vendor selectors and admin-wide integration;
  • packages/frontend/resources/css/base/*.css for public base tokens, typography, links, interactions, and the default theme;
  • packages/frontend/resources/css/capell-frontend.css as the frontend source entry point;
  • packages/installer/resources/css/installer.css for the standalone pre-install UI;
  • packages/frontend/publishes/build/capell-frontend.css as a generated/published artifact, never a hand-edited source.

Custom CSS is justified for vendor DOM that Capell cannot decorate, pseudo-elements, keyframes, complex selectors, CSS variables/tokens, or generated public assets. A new utility-shaped selector is not an exception. Critical CSS is produced by the frontend resource/optimizer pipeline; do not add hand-authored Blade critical-CSS partials.

Anonymous component paths use kebab-case directories and filenames. Declare public inputs with @props and defaults when the view is an anonymous component. Filament-owned views and template views that receive named data from the renderer do not add fake @props declarations.

Narrow union or scalar inputs once near the top of the view, or preferably before rendering. Do not place fully qualified class names or namespace separators in Blade @php blocks; import or prepare the value in PHP code instead. Keep @php blocks small and presentation-only.

  • Public/marketing pages render static server HTML. Do not mount <livewire:...> or @livewire(...) from public Blade.
  • Interactive public widgets must use the documented frontend interaction/runtime boundary and must not expose authoring metadata.
  • Every form control has an associated label or an explicit accessible name.
  • Images have meaningful alt text; decorative images use alt="".
  • Icon-only controls have an accessible label.
  • Interactive elements retain a visible keyboard focus state.
  • Use semantic elements before adding ARIA. Dynamic status messages use the appropriate live-region semantics.

packages/frontend/tests/Arch/PublicBladeSafetyTest.php mechanically protects the public-view boundary.

  • Type every public property. Use nullable types for genuinely absent state rather than sentinel strings.
  • Treat public properties as untrusted request input on every call; validate or narrow before use.
  • Authorize each mutating public method at the point of mutation. Do not rely on mount() authorization because subsequent requests are independent.
  • Public methods that perform work use verb names. Derived, read-only values use computed-property nouns.
  • Keep business operations in Actions; the component coordinates input, authorization, UI state, and notifications.
  • Public marketing output is static SSR unless a documented product requirement explicitly needs the frontend interaction runtime. Admin Livewire use is not affected by that rule.

packages/admin/src/Livewire/PageApprovalStatus.php and the header components are representative typed admin components. Public page Blade remains query-free and Livewire-tag-free even though the frontend package supports controlled renderable widgets elsewhere.

Use it('describes observable behaviour', function (): void { ... });. Descriptions are lower-case prose and complete the phrase “it …”. Do not use snake-case descriptions in new tests. Use describe() only when it removes repeated context or clarifies a coherent state machine.

Each test owns one behaviour or contract. It may use several assertions to prove that behaviour; “one behaviour” does not mean “one assertion”. A regression test names the externally visible guarantee, not the private method that happened to fail.

  • Use datasets for the same behaviour across meaningful inputs, not to hide unrelated scenarios in one callback.
  • Use factories and named factory states for domain records. Inline attributes are appropriate only for the values material to that scenario.
  • Freeze time when an assertion depends on the clock and restore it through the test lifecycle.
  • Fake queues, events, HTTP, storage, and external services at the narrowest boundary.
  • Tests must pass independently and in any order. Root tests/Feature and tests/Integration may cover cross-package contracts; package-owned behaviour stays with its package.
  • Test features that cross systems (registration, relations, admin linking) with real factories and the real page, not mocks. For example, create the model, attach the media, then actingAsAdmin()->get(<the admin page that lists it>)->assertSuccessful(). A mocked relation and an assertion on it can pass while the real page throws.

Assert business logic and externally visible behaviour. Do not assert CSS declarations, custom-property values, Tailwind utility strings or incidental markup nesting (->toContain('inset-inline: 0;'), ->toContain('md:grid-cols-3')). Those encode a design decision as though it were a contract: the test fails on every legitimate redesign, catches no defect and teaches readers to edit assertions rather than trust them.

  • When behaviour is user-visible (a region hidden from assistive technology, a disabled control, a section being present), anchor the assertion to a stable data-capell-* attribute and add the attribute to the markup if it is missing. The attribute is the contract; the styling that reacts to it is not. If removing presentational assertions empties a test, delete it.
  • A string assertion is not presentational by default. Workflow, systemd and config contracts (fail-fast: false, OnFailure=...), drift-detection messages and security-manifest assertions state real guarantees.
  • The deciding question is whether the CSS is the subject’s output or its styling. When a unit exists to produce CSS (ThemeTokenRendererTest asserting --theme-* declarations, TailwindAssetsGeneratorTest, RenderWidgetRuntimeAttributesActionTest asserting ->not->toContain('url(') as a CSS-injection guard), that CSS is its contract and must be asserted. A prohibited assertion reaches through a subject to inspect how something happens to look.

Prefer a readable expectation chain when every link retains the same concrete subject type. Split the chain into separately typed expectations when Pest’s higher-order generic proxy loses the subject type or when the failure message becomes ambiguous. Do not add a PHPStan baseline entry to preserve a clever chain.

packages/frontend/tests/Unit/Actions/BuildPublicRenderPerformanceReportActionTest.php is a compact Action test. The public Blade architecture tests are reference examples for repository contract scans.

Never change an assertion merely to accept output changed by a standards pass. Revert the production change or classify it as a separate behavioural change.

  • Throw a domain/package exception when the caller can recover or when the failure is part of the package contract.
  • Use InvalidArgumentException for programmer input that violates a local API; use a named exception when callers need to distinguish the failure.
  • At HTTP, console, queue, and Filament boundaries, translate a domain failure into the framework response without leaking secrets or internal exception text.
  • Catch only when adding context, translating exception type, performing required cleanup, or executing an explicitly safe fallback.
  • Never use an empty catch. If a failure is deliberately tolerated, state the precise constraint and log unexpected variants at the layer that owns operational reporting.
  • Do not log and rethrow at every layer. The layer with request/job/command context owns the operational log.
  • Logs state the failed operation and include structured, non-secret context. User-facing messages are translated, sentence case, calm, and actionable.

CloudBootstrapCommand::forceAdminPasswordChange() is the reference for a best-effort optional integration that explains the tolerated condition and logs enough context to diagnose unexpected failure.

Comments explain constraints the code cannot make obvious: compatibility, framework limitations, security boundaries, non-obvious performance ownership, or why a safer-looking alternative is invalid. Do not narrate syntax, preserve deleted code, record change history, or leave a bare TODO without ownership and a decision condition.

Every public extension point package authors are expected to implement or call has a documentation-quality docblock. It should answer:

  • what capability the contract provides;
  • when Capell invokes it;
  • what inputs and outputs mean, including array shapes/generics;
  • whether exceptions, side effects, ordering, or idempotency matter;
  • how the implementation is registered or discovered.

Internal methods need docblocks only when they carry generic/shape information or a non-obvious contract. Prefer expressive names and types over ceremonial prose.

Use an anonymous migration class with typed up(): void and down(): void. Tables and columns use snake case, foreign keys end in _id, and indexes/constraints are named only when Laravel’s generated name is unstable or too long.

Package migrations that may run during partial installation, upgrade, or uninstall guard shared/custom tables and columns with the established Schema::hasTable()/hasColumn() checks. A rollback must reverse only what that migration owns; never drop user data opportunistically.

Run all project tooling through the Docker harness:

Terminal window
./capell pint --dirty --format agent
./capell test --compact --filter=RelevantBehaviour
./capell composer analyze

Use the narrowest command that proves the edit while iterating. Run ./capell composer preflight once at the final integration boundary when the task calls for it.

When a rule cannot be followed, document the compatibility or framework reason next to the exception and add the narrowest mechanical allow-list entry possible. An exception must identify an existing contract; “the tool complained” and “this was quicker” are not rationales.