Search by

sinemacula / coding-standards-laravel

sinemacula

Laravel-specific coding standards, static-analysis rules, and code-quality tooling for Sine Macula's Laravel repositories.

Package info

github.com/sinemacula/coding-standards-laravel

Type:phpcodesniffer-standard

pkg:composer/sinemacula/coding-standards-laravel

Statistics

Installs: 19 350

Dependents: 16

Suggesters: 0

Stars: 0

Open Issues: 1

v1.6.3 2026-11-03 01:33 UTC

README

Latest Stable Version PHP Version Build Status Quality Gates Maintainability Code Coverage Total Downloads License

Laravel-specific coding standards, static-analysis rules, and code-quality tooling for Sine Macula's Laravel repositories.

Install this only in Laravel projects. Framework-agnostic, language-wide standards live in sinemacula/coding-standards; this package adds the Laravel-specific layer. Non-Laravel repos simply don't install it - that is how the Laravel rules stay scoped to Laravel projects (no runtime framework detection).

Installation

composer require --dev sinemacula/coding-standards-laravel

This brings sinemacula/coding-standards with it, so don't require the base package as well. You also need squizlabs/php_codesniffer (^3.13.6 || ^4.0.2 - 3.13.5 and 4.0.1 are covered by an advisory that Composer refuses to install), dealerdirect/phpcodesniffer-composer-installer, and slevomat/coding-standard in your dev deps (as you already do for the base standard).

Usage

Wire it into the PHP tools through the same Qlty plugin setup you already use for the base standard. For php-codesniffer that is package_file = "composer.json" with package_filters = ["sinemacula", ...] in .qlty/qlty.toml - the "sinemacula" filter already matches this package, so Qlty installs it into the linter tool environment automatically.

php-cs-fixer is the exception: give it extra_packages instead, listing the standards package and Symfony pinned to ^7.4. Qlty ignores extra_packages whenever package_file is set on the same plugin, and since 3.90.0 the fixer accepts a Symfony that needs PHP 8.4.1 - the sandbox install does not honour platform requirements, so an unpinned Symfony leaves a PHP 8.3 runner unable to parse it.

PHPCS

Two standards ship; reference exactly one (each pulls in SineMacula, so it replaces it - don't reference both):

  • SineMaculaLaravel - for applications. The full standard, including the role-based structure rules (placement and naming of controllers, models, providers, …) and the controller rules.
  • SineMaculaLaravelPackage - for libraries and packages. The same standard with the app-skeleton rules (Structure.*, Controllers.*) excluded, since a package is organised by domain rather than Laravel's app directory layout. Composition only - it redefines nothing.

Both drop the Internal.NoCodeFound warning for *.blade.php files, which hold no PHP to check. Under SineMaculaLaravel the templates are still read, so RequireBladeLocation still reports a misplaced one.

Both also leave line length unchecked in translation files - a file directly inside lang/{locale}/ or Resources/lang/{locale}/ (en, pt_BR, es_419, zh_Hant_TW, ...), or under lang/vendor/ - because a translation string cannot be wrapped without changing what it says.

<?xml version="1.0"?>
<ruleset name="Project">
    <rule ref="SineMaculaLaravel"/>
    <file>src</file>
    <file>tests</file>
</ruleset>

PHPStan

The Laravel rules are auto-included via this package's extra.phpstan.includes (resolved by phpstan/extension-installer), alongside the base config. Your project's phpstan.neon only needs its own paths.

Do not set level. Analysis runs through qlty, whose phpstan driver passes --level=9 on the command line, and a command-line level overrides the config file outright - so a level set here does nothing except mislead whoever reads it next.

PHP CS Fixer

If Laravel-specific fixer rules are present, reference the Laravel factory from your .php-cs-fixer.dist.php; otherwise keep using the base PhpCsFixerConfig::make().

Rules

These are the Laravel-specific rules this package adds on top of the base sinemacula/coding-standards. A deliberate exception can be bypassed with the native directive - // phpcs:ignore <code> for a sniff, @phpstan-ignore <identifier> for a rule.

PHPCS sniffs

Sniff Enforces
SineMaculaLaravel.Architecture.DisallowServiceLocation No service location (app(), resolve(), App::make()) in a class body - inject collaborators instead.
SineMaculaLaravel.Configuration.DisallowEnvOutsideConfig env() only inside config/ files (test code exempt); use config() everywhere else.
SineMaculaLaravel.Controllers.DisallowDatabaseAccess No DB:: facade or direct Eloquent model queries in a controller - read through a repository.
SineMaculaLaravel.Controllers.DisallowInlineValidation No inline validation ($request->validate(), Validator::make()) in a controller - use a form request.
SineMaculaLaravel.Controllers.DisallowNonRestActions A controller's actions are REST verbs or __invoke; @non-rest-action or @utility allows an exception.
SineMaculaLaravel.Debug.DisallowDebugStatements No debug calls (dd, dump, ray, var_dump, print_r); the string-returning print_r($value, true) is allowed.
SineMaculaLaravel.Eloquent.DisallowLegacyAttributeAccessor No legacy getXAttribute() / setXAttribute() accessors on an Eloquent model - use Attribute::make().
SineMaculaLaravel.Routing.CollapseResourceRoutes Routes for one controller that together form a whole resource collapse into Route::apiResource(...)->only([...]).
SineMaculaLaravel.Services.DisallowHttpAbort No abort() / abort_if / abort_unless / HttpException in a service - throw a domain exception.
SineMaculaLaravel.Structure.RequireBladeLocation A *.blade.php template must live under a resources/views (or module Resources/views) directory.
SineMaculaLaravel.Structure.RequireRoleDirectory A class whose role is recognised by identity lives under that role's directory - a controller under Http/Controllers.
SineMaculaLaravel.Structure.RequireRoleNaming A class is named for its role - a required suffix for controllers, providers, form requests, resources and policies.
SineMaculaLaravel.Structure.RoutesLocation A routes.php file, if present, must sit at the root of an Http directory.
SineMaculaLaravel.TypeHints.PropertyTypeHint A class property declares a native type - except the magicProperties set, or a property marked @untypeable.
SineMaculaLaravel.TypeHints.ParameterTypeHint A parameter declares a native type - except where a parent fixes the signature (#[\Override], a trait method).
SineMaculaLaravel.TypeHints.ReturnTypeHint A function, method or closure declares a native return type - except constructors/destructors/clone handlers and methods carrying #[\Override].

Service location

The rule targets production code. Exempt are test files; container-wiring classes, recognised by a Providers namespace, a Provider/Registrar suffix or a ServiceProvider/Registrar base; classes the framework constructs itself and so leaves no injection point (the uninjectableBaseClasses set, defaulting to Eloquent's Factory, JsonResource and ResourceCollection, and the API toolkit's ApiResource and ReportResource, whose schema() is static); and resolution of a runtime variable, app($class), which injection cannot replace.

An entry in uninjectableBaseClasses containing a \ is matched against the parent resolved through the file's imports, so an unrelated Illuminate\View\Factory is not exempted. A bare entry matches the short name, which is how a project adds an intermediate base of its own.

Debug statements

Only the return form is left alone: print_r($value, false) and the bare print_r($value) are still flagged, and var_dump has no return form at all.

Resource routes

CollapseResourceRoutes is deliberately conservative. Any extra or nested route, a renamed action, an inconsistent parameter, or a per-route modifier beyond the URI-constraint family leaves the group untouched, and routes are only grouped within their enclosing group() closure.

Role-based structure

RequireRoleNaming and RequireRoleDirectory resolve a class's role by identity first - what it extends, implements, uses or is attributed with - and fall back to its location (a concrete class under a role directory, minus exempt sub-namespaces such as Concerns/Support/Contracts). A class with neither is unconstrained, so genuine domain classes are never flagged.

The default role table is convention-correct for Laravel: it never requires a suffix the framework leaves bare, and the idiomatic bare User model is honoured (the Model role's identity covers Authenticatable, Pivot and MorphPivot). Every list - roleIdentities, roleLocations, requireSuffix, forbidSuffix, exemptNamespaces, moduleRootRoles - is a public sniff property a ruleset can override. Identity is matched on the immediate base by short name, so a project's own intermediate base (e.g. a BaseController) is supported by adding it to roleIdentities. An identity entry containing \ instead matches the qualified name resolved through the file's imports, which keeps same-named traits distinct: the Job role's Bus\Dispatchable entry identifies a sync job without ever matching the Events Dispatchable an idiomatic event uses. Test classes - by name, test-case base, or a tests/ path - play no role at all, so a test mirroring a role namespace (Tests\Unit\Policies\...) is never constrained.

Opt a class out entirely with an @role-exempt docblock tag or a #[NotARole] attribute (matched by short name; SineMacula\CodingStandardsLaravel\Attributes\NotARole ships so the reference also resolves under static analysis). Both hatches work with other attributes present on the class.

Type hints

The TypeHints.* sniffs replace the base standard's Slevomat native-type requirements with Laravel-aware equivalents (the base Slevomat MissingNativeTypeHint / MissingAnyTypeHint codes are excluded; the traversable-specification checks are kept). They are needed because the Slevomat sniffs are inheritance-blind: PHP forbids typing a property that overrides an untyped parent ($table, $fillable, …) or changing an inherited method signature, so the original rules force types that fatal at class load. Native types are still required everywhere else. The exempt property set is the configurable magicProperties list on PropertyTypeHint, and an overriding method opts its signature out with the native #[\Override] attribute. A non-private trait method is also exempt from the parameter requirement, since its effective parent is whatever the consuming class extends and a token sniff cannot resolve that.

magicProperties covers the framework base classes an application extends - models, factories, commands, migrations, API resources, middleware, the HTTP/console kernels, service providers and the exception handler. It cannot know about third-party or application base classes, so a property whose untyped parent declaration is out of the list's reach is marked @untypeable on its own docblock:

/**
 * The vendor base class declares this untyped, so PHP forbids typing it here.
 *
 * @untypeable
 */
protected $view;

Readonly properties

The base standard requires every public property to be readonly. An Eloquent model is exempt: it overrides the framework's public magic properties ($timestamps, $incrementing, …), which are declared non-readonly on the base class, and PHP forbids making an inherited property readonly. This standard sets the base sniff's ignoredParentClasses to the model bases (Model, Authenticatable, Pivot, MorphPivot), matched by the immediate parent name as written.

A class that implements ShouldQueue, ShouldQueueAfterCommit or ShouldBroadcast is exempt for the properties the framework reads off it - $tries, $timeout, $backoff, $queue, $connection and the others the ruleset lists - because each needs a default and a readonly property cannot have one. Its other public properties are still required to be readonly. The interface is matched as written in the class's own implements clause.

Policy abilities

A policy's methods are named for the abilities the Gate checks (viewAny, update, before, ...), so they cannot take a predicate prefix. BooleanMethodName leaves classes whose name ends in Policy alone.

PHPStan rules

Identifier Enforces
sineMaculaLaravel.castsProperty No $casts property on an Eloquent model - use the casts() method.
sineMaculaLaravel.datesProperty No $dates property on a model (deprecated) - cast dates via casts().
sineMaculaLaravel.massAssignment Every concrete production model declares mass assignment via $fillable/$guarded or the attribute form.
sineMaculaLaravel.fillableCasts Every $fillable entry on a model declares a matching cast, documenting each settable field's type.
sineMaculaLaravel.relationshipReturnType A relationship method declares a return-type hint.
sineMaculaLaravel.modelBehaviour A model, at any depth of ancestry, carries its schema and its relations; behaviour belongs to a repository or a service.
sineMaculaLaravel.modelAttribute Prefer a model attribute over its legacy property or method form, for the attributes a project enables.
sineMaculaLaravel.modelAttributeLaggingFloor The legacy form of an attribute the project already resolves a Laravel version for, while its floor does not.
sineMaculaLaravel.queueAttribute A queued class sets $tries, $timeout, $queue and the other queue settings with their attribute, not a property.
sineMaculaLaravel.queueAttributeLaggingFloor A queue setting property the project's resolved Laravel could replace, while its floor does not.
sineMaculaLaravel.foreignIdFor A foreign key column in a migration is declared from its model - foreignIdFor(Organization::class).
sineMaculaLaravel.migrationMethods A migration defines both up() and down().
sineMaculaLaravel.schemaNaming Table and column names in a migration use snake_case; digits are allowed, only casing is enforced.
sineMaculaLaravel.formRequestRules A form request, recognised by its ancestry, has a rules() method, its own or inherited; tests are exempt.
sineMaculaLaravel.factoryTimestamps A factory definition() must not set created_at / updated_at.
sineMaculaLaravel.resourceFieldNaming Field keys in a resource's toArray() result use snake_case, nested arrays included.

What a model may carry

modelBehaviour limits a model's own surface to what describes a record. Permitted are its relations, recognised the same way relationshipReturnType recognises them; accessors and mutators, recognised by an Attribute return type rather than by name; the framework hooks named in sineMaculaLaravel.modelHooks, described below; and any public method declared by an interface the model's own author opted into.

Every method the class declares is read, so moving one into a trait does not take it out of scope, and an anonymous class extending a model base is read like any other. A method a trait imports is reported against the line the class declares itself on, because the trait may live in another file. A trait is only read where its file is among the analysed paths, though, so one installed as a dependency is reported only in the package that ships it, where that package's own classes import it. An application importing it gets no report and needs no config.

A model is recognised through its whole ancestry, so a project that puts its own base class between its models and Model, Authenticatable, Pivot or MorphPivot is covered like any other. It is the only model rule that does: castsProperty, massAssignment, fillableCasts and modelAttribute each match the immediate parent name as written, and datesProperty does not look at the parent at all - it reports a $dates property wherever one appears.

The parent name as written is read alongside the resolved ancestry rather than only when nothing resolves, so a model whose parent is written as Model, Authenticatable, Pivot or MorphPivot stays covered where a dependency is only partly installed and the ancestry above that parent is invisible. What name-based recovery cannot reach is a base class of the project's own, named something else, that resolves while the framework base above it does not: nothing in the model or its resolved ancestry names a model base, and it is left alone.

The interface exemption covers only the contracts the model's own author opted into, whether the class, a base class of the project's own or a package declares them. The contracts the framework base already carries are excluded: Model itself implements Arrayable, ArrayAccess, JsonSerializable and six more, so honouring those would exempt toArray(), jsonSerialize() and the array-access methods on every model there is - and toArray() is the canonical way behaviour gets smuggled onto one.

An abstract method inherited from a base class is not exempt either. That obligation is written in the project itself, and the rule already reports it where the base declares it, so exempting the implementations would turn one suppression on a base class into unpoliced behaviour on every model beneath it. The mandate and each implementation of it are reported separately; where the mandate is deliberate, silence each report with @phpstan-ignore sineMaculaLaravel.modelBehaviour in the method's own docblock. A report against a trait-imported method is anchored to the class declaration rather than the trait, so it is silenced there rather than in the trait.

Only public methods are reported: a non-public helper called from a relation is detail, whereas a public one is what other layers reach for. A query scope is the exception, reported whatever its visibility, because the attribute form is conventionally not public. It belongs on a repository, which can compose it as an ordinary fluent method instead of a magic call on the builder.

Framework hooks

A method is a default hook only if it passes all three tests:

  1. Eloquent declares it, publicly - on Model, on a concern Model composes, or on an Eloquent trait a model opts into. Only public methods are reported, so a protected entry would do nothing.
  2. Only an override can say it - no property, attribute or constant on the model says the same thing on every Laravel version this package supports.
  3. It describes rather than performs - it returns a name, class, list, generated value or unexecuted query. It does not run a query, return a fetched record, change state or call anything external.

The defaults are uniqueIds, newUniqueId, getRouteKeyName, resolveRouteBindingQuery, getMorphClass, prunable, getTable and getConnectionName. getRouteKeyName stays only while Laravel 12 is supported, since #[RouteKey] arrives in 13. Passing the tests makes a method eligible, but it is only added once a real project needs it.

getTable and getConnectionName are where a table or connection taken from config goes. $table, $connection and their attributes only take constant values, so the override is the only way to say it, and it replaces setting either one from a constructor, which is reported like any other behaviour. Fall back to config rather than replacing the property, so setTable(), setConnection() and Model::on() keep working:

public function getTable(): string
{
    return $this->table ?? config('audit.table');
}

public function getConnectionName(): ?string
{
    return $this->connection ?? config('audit.connection');
}

A fixed name still belongs in #[Table] or $table.

The route binding resolvers - resolveRouteBinding(), resolveSoftDeletableRouteBinding(), resolveChildRouteBinding() and resolveSoftDeletableChildRouteBinding() - fail the third test, because each runs the lookup and returns the record. resolveRouteBindingQuery() and getRouteKeyName() already cover any change to the query's shape, so overriding a resolver only adds something when it does more than shape the query. That is behaviour, and a project that wants it appends the resolvers in its own PHPStan config (.qlty/configs/phpstan.neon where qlty runs the analysis):

parameters:
    sineMaculaLaravel:
        # Route binding that does more than shape the query: <the project's reason>.
        modelHooks:
            - resolveRouteBinding
            - resolveSoftDeletableRouteBinding
            - resolveChildRouteBinding
            - resolveSoftDeletableChildRouteBinding
  • Append, don't replace. Use modelHooks:, not modelHooks!:, and don't copy the defaults; they still apply.
  • List all four resolvers, not only the ones overridden. The framework picks one by the route's soft-delete and scoping settings, so overriding only resolveRouteBinding() means a withTrashed() or scoped route skips the custom logic without any warning.
  • State the reason in a comment. The list applies to every model in the project, so it records a project-wide decision.

A hook from a package outside laravel/framework, such as Scout's toSearchableArray(), is appended the same way.

Situation Use
Any model in the project may do this modelHooks in the project's config
One model, or a few, as a deliberate exception @phpstan-ignore sineMaculaLaravel.modelBehaviour in the method's docblock, or on the class line for a method a trait imports
The method comes from a trait installed as a dependency Nothing - the trait is not read

Don't declare an interface just to reach the interface exemption. It does silence the rule, but the exemption exists for contracts that come from outside the model, not as another way to permit a hook.

Model attributes and the version gate

modelAttribute mandates the attributes a project enables - by default #[Table], #[Fillable] and #[Hidden], configurable through sineMaculaLaravel.modelAttributes. Those landed in Laravel 13.2, so they are enforced only once the project's floor reaches it, taken from sineMaculaLaravel.minLaravelVersion or detected from the nearest composer.json.

Where the floor sits below 13.2 but composer.lock already resolves above it, the legacy form is reported under modelAttributeLaggingFloor instead. The attribute cannot be adopted until the floor moves, but the gap stays visible rather than arriving as a bulk migration the day it does. A package that supports older versions deliberately can ignore that identifier and keep the enforcement one.

Queue attributes

queueAttribute reports a public property declared only to give a queue setting a value - public $tries = 3; on a job - in favour of its attribute, #[Tries(3)]. It reads every class the framework queues: jobs and listeners (ShouldQueue), mailables, notifications and broadcast events (ShouldBroadcast), each as its most specific kind.

Each setting is enforced from the floor at which the attribute behaves like the property on every path the framework takes for that kind. Nothing is enforced below 13.4, the first release in which a value set at runtime, such as by onQueue(), wins over the attribute as it does over a declared default. Some settings wait longer: a job's #[Delay] until 13.24, when every queue driver honours it, and a mailable's #[FailOnTimeout] until 13.36. The floor comes from sineMaculaLaravel.minLaravelVersion or the nearest composer.json, read from the queue-side components (illuminate/queue, illuminate/bus, illuminate/mail, ...) as well as illuminate/database and laravel/framework, taking the lowest any of them declares, since each component reads only its own kinds' attributes. A floor behind what composer.lock resolves reports under queueAttributeLaggingFloor, as the model attributes do.

A property is left alone where the attribute would not say the same thing:

  • a default the attribute cannot carry - a notification's per-channel $delay array, a $failOnTimeout of false (#[FailOnTimeout] and #[DeleteWhenMissingModels] take no argument), or no default at all;
  • a setting the class answers with a method the framework calls instead - tries(), backoff(), uniqueFor(), viaQueue(), viaConnection(), withDelay() and a notification's viaQueues() and viaConnections();
  • $queue, $connection and $delay on a class using Queueable, whose onQueue(), onConnection() and delay() - and a chain's - change them at runtime, including back to null, which an attribute cannot follow;
  • a broadcast event's $queue and $connection, which the broadcaster reads before the attribute, so an inherited default would win over it;
  • a non-public or static property, which the framework never reads.

What the model and migration rules inspect

fillableCasts runs only on a concrete model directly extending Model/Authenticatable/Pivot/MorphPivot, and skips a $fillable or casts() that is not a plain enumerable literal. schemaNaming and resourceFieldNaming inspect string literals only, so computed keys and dynamic names are left alone. foreignIdFor flags the longhand foreignId/foreignUuid/foreignUlid calls whose literal column name ends in _id; bypass a column pointing at a table this service does not own with @phpstan-ignore sineMaculaLaravel.foreignIdFor. Classes declared in tests are exempt throughout.

Builder calls under the strict rules

Larastan reflects every method forwarded onto an Eloquent builder - the query methods and any #[Scope] - as a static method, so phpstan-strict-rules reads ordinary query code as a dynamic call to a static one:

Dynamic call to static method Illuminate\Database\Eloquent\Builder<App\Models\Credential>::whereNull().

That lands on essentially every query in a project, so this standard ignores it, scoped to the framework builders rather than turning the rule off - a genuine dynamic call to a static method elsewhere is still reported.

A model pointing at its own builder with #[UseEloquentBuilder] is reported against that class instead, which no shipped pattern can name. Add the matching entry to the project's own config:

parameters:
    ignoreErrors:
        -
            identifier: staticMethod.dynamicCall
            message: '#^Dynamic call to static method App\\Builders\\#'

Where model columns come from

This section, and the builder ignore above it, describe behaviour that comes from larastan. The package suggests it rather than requiring it, so that a project analysing without it is not made to install a framework it does not use; the rules this package ships work either way, and the settings written for larastan are inert without it rather than fatal.

A model's columns are read from its migrations, and everything that rests on them - the property checks, the cast types, the model property of ... parameter types - is only as good as that scan. Left unset, larastan resolves the path from the booted application rather than from the project being analysed, and an isolated runner can boot it somewhere else; the two then disagree, no migration is found, and the checks quietly stop covering anything. The standard names the conventional path so it is pinned to the project:

parameters:
    databaseMigrationsPath:
        - %currentWorkingDirectory%/database/migrations

Replace it where migrations live elsewhere, or are kept per module - the entries accept globs, and databaseMigrationsPath!: replaces rather than appends:

parameters:
    databaseMigrationsPath!:
        - %currentWorkingDirectory%/database/migrations
        - %currentWorkingDirectory%/app/*/Database/Migrations

If the scan finds nothing the failure is loud rather than silent: with no columns to resolve against, every model property is reported as undefined.

Where cast types come from

A cast changes a property's type, so casts() has to be read for the column types to be right. By default larastan reads only its declared return type, and the @return array<string, string> a documented casts() carries says nothing about any one column: every datetime, json, enum and custom cast types as the raw column, and the strict rules then report ordinary Carbon, array and enum use as errors. The standard enables parseModelCastsMethod, so larastan reads the array casts() returns instead.

Requirements

  • PHP ^8.3

Testing

composer test                # PHPUnit sniff/rule suite
composer test:coverage       # suite with Clover coverage output (requires Xdebug)
composer test:mutation       # Infection mutation gate (min MSI 90)
composer test:mutation:full  # full mutation suite without thresholds
composer analyse             # PHPStan over the package's own sniffs and rules
composer check               # static analysis and lint via qlty
composer format              # format via qlty
composer smells              # duplication / complexity smells via qlty

Changelog

See CHANGELOG.md for a list of notable changes.

Contributing

Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.

Security

If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.

License

Licensed under the Apache License, Version 2.0.