Loading...

This is taking longer than expected.

Coding standards

PHP

These are the standards the analyzer applies. Every rule states what it checks and why it exists.

All standards

Language & Text

Code identifiers and comments must be in English.
User-facing non-English copy must live in translation files, not directly in PHP/Blade source. Report this rule only when the text is clearly rendered to the user, such as labels, buttons, headings, notifications, dialogs, validation messages, emails, or Blade output.
Do not use this rule to require translation wrappers for already-English text. Translation helper usage is a separate consistency concern.
When adding new localized user-facing copy, use translation helpers and update every project locale JSON file that exists for the UI, commonly lang/en.json and lang/es.json. Do not add the helper in PHP/Blade without verifying or adding the matching entries in those locale files.
When fixing user-facing non-English copy, prefer a literal translation key such as __('Format saved'), __('Edit'), or __('Remote'). Do not invent abstract, numbered, variable-like, or namespaced translation keys such as __('validator.text1'), __('message_1'), or __('module.section.label') unless the project already has that convention or the current change explicitly adds the required translation entries as part of a deliberate i18n change.
When replacing user-facing non-English copy with a translation helper, update the appropriate JSON translation file in the same change unless the key already exists. Preserve the original visible text exactly as the translation value, including accents, punctuation, capitalization, and spacing. Do not leave the source code changed to __('English key') without adding or verifying the matching JSON entry.
Long-form copy is the exception to the literal key. A legal page, a policy, or an email body is a block of prose with no short literal that could name it, and it is rewritten over time without the code changing. Give it a named key in a locale file of its own, such as lang/es/legal.php, instead of a JSON entry. The block keeps the inline markup that belongs to it — a <strong> around a defined term, a bold label opening a list item — and the page renders it unescaped, passing any value it interpolates already escaped.
A date that tells the reader when a document was last revised belongs to the copy, beside the text it dates. Do not derive it from the filesystem: filemtime() in a Blade file reads the compiled view, so clearing the view cache silently restamps a legal document as revised today, and even against the source it would move whenever the markup is touched for unrelated reasons.
Move long-form copy in the language it was written in, then translate it in a separate change. Relocating and translating at once leaves a diff where nothing can be checked: a clause that was dropped in the move looks exactly like a clause that was reworded in the translation. Moving first keeps the text verifiable against the page it came from.
Do not apply the user-facing copy rule to tests that assert expected rendered text. Test assertions may contain or reference the exact non-English copy they verify because they are not themselves rendered to users.
Do not apply the user-facing copy rule to internal persisted values, enum/database values, array keys, route names, event names, config keys, or comparison tokens that are not directly rendered to users. For example, a status-to-color map keyed by stored Spanish status values is internal logic, not visible copy.

Code Cleanliness

Keep code direct and readable:

  • Inline single-use variables when they do not clarify meaning.
  • Inline tiny single-use helper methods when the name does not add a useful concept.
  • Keep a comment to a label of four words or fewer; what it would have explained belongs in the name, or in docs/{project}. See Comments Earn Their Place.
  • Omit the method return type, except when a contract/framework override requires it. Omitting it is not a reason to write the same type in a PHPDoc instead.
  • Use long-form function closures; never use arrow functions (fn).

Return types are allowed when they are required by a parent class, interface, abstract method, framework contract, or when the project convention clearly uses them for small predicate/calculation helpers.

Comments Earn Their Place

The name is the documentation. A method whose name says what it does needs nothing above it, and a comment written anyway is a second description that ages on its own, until it contradicts the code nobody updated it with. When the code needs explaining, the explanation belongs in docs/{project}, where a reader who is not opening this file can find it — that is what the developer documentation is for.
Annotations aside — a block that only signals something to a tool, below — one test decides every comment in a file: is it a label, or is it an explanation?
A label names something on one line, in at most four words, with no closing period and no @ tag. It is too short to hold an explanation, so it has nothing that can age out of step with the code. It passes anywhere — above the class declaration, between methods, inside a body, inside a method left empty on purpose. Commented-out code is never a label, whatever its length: it is not a name, and deleting it is the whole fix. An empty // holding a block open, such as an empty catch, is a label with nothing in it and passes the same way — but it records nothing, so it does not mark a method as empty on purpose, and No Empty Methods still reports that body. A label's form is free everywhere except at class level — inside a class body but outside a method body — where it divides and is written in the /* */ form given below.
An explanation is anything else: more than one line, more than four words, a closing period, or an @ tag. It is reported as a warning, so nothing blocks on it, but that is not permission to leave it. Move the explanation first, then delete the comment: what it holds — why the flow is shaped this way, what the outside constraint is, what breaks if it changes — goes into the docs/{project} document that already owns the subject, never a page of its own. Writing it belongs to the documentation step rather than to the run that deleted the comment, so name it as developer documentation still owed and let /develop write it in the run that makes the change, /finish when a sweep finds it was never written, and /document for a pass of its own. Most comments hold nothing the code does not already say: there is nothing to move, and deleting them is the whole fix. When the name is what failed, rename the method until it says what the code does.
Configuration, migrations, tests and route files are exempt, the same set Class Size leaves alone: a configuration file published by a package carries the vendor's own documentation of every option, and deleting it separates the file from the original that the next publish restores. Everywhere else, no place in a file is outside this rule. A block above the class declaration is judged like any other: four words or fewer and it is a label, longer and it is an explanation that belongs in docs/{project}. One place in a file is judged elsewhere: a comment above a route registration belongs to Laravel Routes, which asks for an exception comment saying why the route is not a Livewire UI flow, so a block sitting above a Route:: call stays however long it is.

// Correct — the name says it, so nothing sits above it
public function forgetCachedPermissionsForTeam($teamId)

// Correct — a label dividing a class, in the `/* */` form a division takes
/*
 * Relationships
 */

// Incorrect — an explanation above the class, and `docs/{project}` is where it goes
/*
 * The country decides the currency and the price a user is offered, so a
 * project that sells subscriptions cannot let anyone in without one.
 */

class EnsureHasCountry

// Incorrect — describes the method instead of naming it
/**
 * Bump the generation so every team key changes at once, which is how a
 * single write invalidates all teams on a cache store without tags.
 */
public function forgetCachedPermissionsForAllTeams()

A label that divides the methods of a class is written in the multi-line /* */ form the rest of these standards use. The model, trait and observer standards require that division in the cases they name, and the Livewire standard allows it; the form is reported on its own, as a warning to rewrite rather than to delete. This applies only to a label sitting at class level: inside a method body a label passes in any form, because there it marks a step instead of dividing a class.

// Incorrect — at class level, a division written as a docblock
/**
 * Functions
 */

// Incorrect — at class level, a division drawn with rules
// ────────────────────────────────
// Subscription actions
// ────────────────────────────────

An annotation is not a comment about the code: it is a signal something else reads. A block holding @see, @link, @deprecated or a bare URL — and nothing else — passes, and so does a @var on a local variable inside a method body, because deleting it turns off an editor warning or a type hint that docs/{project} cannot replace. A @var above a declaration is not that case; it fails, below. Prose does not travel with the annotation: a block that carries both is reported, the explanation goes to docs/{project}, and the block is left holding the annotation alone.

// Correct — a reference no code carries
/**
 * @see https://heroicons.com/
 */
public $icon = 'circle-stack';

// Incorrect — an explanation riding along with the reference
/**
 * @see https://stripe.com/docs/billing
 *
 * The charge is retried three times because the gateway reports every
 * attempt separately and the last one decides the cancellation.
 */

A @param, @return or @var above a declaration is the one case that fails rather than warns. It says again what the signature already carries — delete the tag lines, and the whole block when nothing else is left. Omitting a return type is not an invitation to write that type above the method instead, and a shape does not rescue it: @return array{type: string, value: string} is still the signature written twice, in a place that ages separately from it. The @var that stays is the one on a local variable inside a method body, which tells an editor what no declaration says.

// Incorrect — repeats the signature
/**
 * @param  int|string|Model|null  $teamId
 */
public function forgetCachedPermissionsForTeam($teamId)

Multi-Line Control Structures

Control structures must put their body on its own line, wrapped in braces. Never write the condition, opening brace, body, and closing brace on the same line.
This applies to if, elseif, else, for, foreach, while, do ... while, switch, try, and catch. It also applies to a braceless body written on the condition's line, such as if (! $user) return null;.
A closing brace may share its line with the keyword that continues the structure, such as } else {, } elseif (...) {, } catch (...) {, or } while (...);. That is the formatter's output and is correct. What must never share a line is the body.
Report this rule whenever a control structure's body shares a line with its condition, even when the body is a single short statement, even when the code is otherwise correct, and even when the formatter has not been run. This is a readability rule, not formatter-owned whitespace — it is reportable on its own.
Do not extend this rule to expressions. Chained collection pipelines, closures passed as arguments, match arms, and array literals are not control-structure bodies.

// Correct
if ($user->referral_code) {
    return $user->referral_code;
}

do {
    $code = strtoupper(Str::random(8));
} while (User::where('referral_code', $code)->exists());

// Incorrect
if ($user->referral_code) { return $user->referral_code; }

do { $code = strtoupper(Str::random(8)); } while (User::where('referral_code', $code)->exists());

if (! $user) return null;

Multi-Line Arrays

Empty short arrays, one-item arrays, two-item arrays within the line length below, and simple lists may remain inline. A short array with three or more direct items must put [ and ] on separate lines, with each direct element beginning on its own line, when it has a => key or a direct value longer than 30 source characters. PHP attributes are metadata and may keep their arrays inline.
A short array whose items all carry => keys must also wrap when the line it is written on is longer than 100 characters, even when it has only two items. What is measured there is the whole line and not the array, because the indentation is part of the problem: two => pairs that fit on a short line read fine, and the same two pushed past the edge of the screen do not. A one-item array is not measured this way, because its length is its single value and moving it to another line carries the same length along. An array with any unkeyed item may stay inline whatever the line's length.
This is an explicit readability exception to formatter-owned mechanics. Report it even when the formatter has not run. Do not apply it to long array() syntax or array access.

// Correct
return [
    'success' => false,
    'message' => __('The meter :key does not exist in this project.', ['key' => $key]),
];

['required', 'string', 'max:255'];

['name' => $eventName];

// Incorrect
return ['success' => false, 'message' => __('The meter :key does not exist in this project.', ['key' => $key])];

['name' => $eventName, 'title' => $eventTitle, 'type' => $eventType];

Formatter-Owned Style

  • Follow the project's configured formatter as the style reference for purely mechanical whitespace and token style. This does not authorize running the formatter.
  • Do not report formatter-owned style as a manual cleanup finding unless it contradicts the formatter output or the formatter is unavailable.
  • Multi-line control structures and multi-line arrays are explicit exceptions: report them under their rules regardless of formatter availability. The formatter produces the same result, so this does not contradict its output.
  • Do not run Pint, PHP-CS-Fixer, or any formatter command to fix or validate these standards unless the user explicitly asks for formatting. Formatter execution is handled separately by project maintainers.
  • For string concatenation, follow the active Weblabor Coding Standards formatter configuration. In projects that use weblabormx/weblabor-cs, concatenation uses one space around the . operator: $prefix . '_suffix'.
  • Do not "clean up" concatenation by removing those spaces in projects using the Weblabor formatter config.
// Correct
public static function all()
{
    return Plan::all();
}

// Incorrect
public static function all(): Collection
{
    return Plan::all();
}

Exception: When a method must keep a return type to satisfy a PHP compatibility contract, you must keep the same return type. This includes overriding a parent method, implementing an interface method, fulfilling an abstract method, or preserving a framework/library contract that the class explicitly declares. Removing it causes a fatal error or breaks the declared integration.
Laravel migration methods up(): void and down(): void are allowed because they follow Laravel's migration stub/contract conventions. Do not fail those return types.

// Correct — parent declares BelongsToMany, override must match
public function roles(): BelongsToMany
{
    return $this->belongsToMany(Role::class, 'role_has_permissions');
}

// Incorrect — omitting the type breaks compatibility with the parent
public function roles()
{
    return $this->belongsToMany(Role::class, 'role_has_permissions');
}

// Correct — interface declares array, implementation must match
public function set($model, string $key, $value, array $attributes): array
{
    return [$key => $value];
}

Before removing a return type, check whether the method satisfies any declared parent, interface, abstract, or explicit framework contract. If it does, keep the type.

Class Declarations (Namespaces Only)

Use imports for real class references. Do not write fully qualified class names inline in executable code, types, catches, or ::class references.
This rule applies only to actual fully qualified class references such as new \App\Services\Billing\PlanSyncService(), \Throwable $e, or \App\Models\User::class. It does not apply to quoted strings, route/resource names, config keys, translation keys, helper/function calls, short class names that resolve in the current namespace, or relative qualified names without a leading backslash. For example, Commands\OptimizeBladeIndentation::class inside namespace App\Console; resolves to App\Console\Commands\OptimizeBladeIndentation and does not require an import.
It also does not apply to files under config/. A config file returns a plain array and names classes inline by framework convention, including the files published by Laravel and its packages. bootstrap/providers.php is the same list under another name: Laravel appends to it itself when a provider is generated, writing the fully qualified name each time, so an import there would be undone by the next make:provider.

// Correct
namespace App\Console;

use App\Services\Billing\PlanSyncService;

return new PlanSyncService();
Commands\OptimizeBladeIndentation::class;
Route::front('Team\\Supplier');

// Incorrect
return new \App\Services\Billing\PlanSyncService();

Avoid Constants

Do not declare constants for ordinary project values. A constant lifts a value to the top of the class, away from the only code that cares about it, and every reader has to travel there to learn what it holds.
When one place reads the value, write it in that place. A name with a single reader repeats what the line already says.
This rule is about const declarations. A define() call is not one: in a Laravel project the only one is LARAVEL_START in public/index.php, which the framework ships and rewrites on every update.

// Correct
public function scopeVisible($query)
{
    return $query->whereIn('status', ['draft', 'sent']);
}

// Incorrect
const VISIBLE_STATUSES = ['draft', 'sent'];

public function scopeVisible($query)
{
    return $query->whereIn('status', self::VISIBLE_STATUSES);
}

When several places read it, assign it to a variable where the work happens, or return it from a method that names it. Either one keeps the value next to its use and lets it be built, filtered, or passed like any other value, which a constant cannot be.

// Correct
public static function visibleStatuses(): array
{
    return ['draft', 'sent'];
}

A value that changes with the environment is not a constant either: it belongs in config/, read with config().
define() is the same declaration with another spelling, and the rule reads it the same way.

No Explicit Exceptions

A model or an observer must not throw ValidationException. Validation belongs where the input arrives — a Livewire component, a Form Request, a rule class, or the service that orchestrates the write — and by the time a record reaches the model the answer to "is this allowed" is already settled.
A model that validates has two callers it cannot serve at once: the form, which needs the message on the field, and the job or the seeder, which only needed the row written.

// Correct — the component decides, the model writes
public function save()
{
    $this->validate();
    $this->order->markPaid();
}

// Incorrect — inside the model
public function markPaid()
{
    if (! $this->total) {
        throw ValidationException::withMessages(['total' => __('Missing total')]);
    }
}

Restating a Default

Do not write what the framework, the package, or the parent class already does. An override that returns the value it inherited, an argument passed what it already defaults to, a property assigned the value it was born with — each one reads as a decision and is not one, and a reader spends the lookup before finding that nothing changed.
Declare the override where the answer differs. Restating a default also hides the moment it stops being one: when the parent changes its answer, the copy keeps returning the old one, and nothing says so.

// Correct — the action inherits hasPermissions() returning true
class GoToCategories extends Action
{
    public function handle()
    {
        return redirect('/app/categories');
    }
}

// Incorrect
class GoToCategories extends Action
{
    public function hasPermissions($object)
    {
        return true;
    }

    public function handle()
    {
        return redirect('/app/categories');
    }
}

This is not the same as a no-op a contract requires, which No Empty Methods covers: that method has to exist, and the label above or inside it is what records that the empty body is deliberate.

No Empty Methods

A method with an empty body does nothing and says nothing. Delete it.
Scaffolding is where these come from: an observer generated with all seven hooks, a class stubbed from a template. A hook that does not exist behaves exactly like a hook that is empty, so leaving it costs a reader a lookup and returns nothing.
Two cases are not violations, and both are recognized. A no-op required by a parent, an interface, or a framework contract stays, because removing it breaks the declaration. And an empty body marked by a label stays, because the label is the part that was missing — a label as Comments Earn Their Place defines it, four words or fewer, so it records that the body is empty on purpose without becoming an explanation that ages.

// Correct
public function down(): void
{
    // Nothing to reverse
}

// Incorrect
public function updated(Order $order)
{
}

Lifecycle Method Order

Lifecycle methods run in a fixed order, and the class reads in that order too:

  1. __construct()
  2. mount()
  3. every other method
  4. render()

render() is last because it is the end of the story: everything above it is what the class prepares, and a reader who opens the file wants the setup before the output. Declaring it early pushes the work below the thing that consumes it.

What an Entry Point May Call

An entry point calls a model method. The model reaches the service or the job.
A Livewire component, a controller, or a command is where a request arrives, not where a domain action lives. When one of them calls a service directly, resolves it from the container, or dispatches a job itself, the action stops having a name on the record it belongs to, and the next caller has no way to find it.

// Correct
$subscription->cancel();

// Incorrect — from a component
app(SubscriptionService::class)->cancel($subscription);
CancelSubscriptionJob::dispatch($subscription);

An observer is not an entry point: reacting to a saved record is exactly where calling a service belongs. A model may reach the service it owns. And a service that receives no record is not performing a domain action, so calling it is fine.

Framework & Helper Preference

Prefer Laravel Collections, Laravel helpers, facades, and existing project helpers when they are clearly more expressive or more consistent than native PHP for that concern.
Report this rule only when a concrete existing abstraction is a better fit in the reviewed context. Native PHP is fine for simple local operations such as scalar formatting, array mechanics, and one-off file reads/writes unless the project already standardizes that exact concern behind a helper/facade.

Shared Utilities Belong in Helpers

A pure function that only transforms its arguments — formatting, normalizing, or parsing a value — does not belong to any class. When more than one place needs it, put it in a project helper.
Do not park it on the class that happened to call it first. A phone normalizer on the User model, a string sanitizer on a service, or a date formatter on a controller are all the same mistake: the function describes a value, not that class.
Do not extract it as a trait either. A trait makes it a method of every class that uses it, which is the outcome being avoided. Reserve traits for behavior that operates on $this.

// Correct — app/helpers.php
function normalize_phone_number($phoneNumber) { ... }

// Incorrect — parked on the first caller
User::normalizePhoneNumber($phoneNumber);

When only one place needs the utility, keep it private next to the code that reads it. Move it to a helper when a second caller appears.

Relationship-Based Model Creation

When creating a related model, use the owning Eloquent relationship.
Apply this rule anywhere in the PHP/Laravel application when the parent model instance is already available and the related record belongs to that relationship. It is not limited to code written inside the model class itself.

// Correct
$plan->limits()->create(['limit_key' => 'activities', 'limit_value' => 100]);

// Incorrect
StripeLimit::create(['limitable_type' => Plan::class, 'limitable_id' => $plan->id, ...]);

Eloquent Query Building

A query is built the same way wherever it is written. This rule applies to any PHP file that composes an Eloquent chain — models, Laravel Front resources such as indexQuery(), Livewire components, services, and jobs alike.
Do not use Model::query() in ordinary Eloquent chains. Prefer calling scopes and builder methods directly on the model, such as Project::active()->orderBy('name')->get(), instead of Project::query()->active()->orderBy('name')->get().
Model::query() is allowed only when the code truly needs an explicit Eloquent\Builder instance, such as passing a builder to another API, returning a typed builder from a method, or building a query from a dynamic model class like $modelClass::query(). Do not add query() only to start a normal chain.

// Wrong
return Project::query()
    ->active()
    ->orderBy('name')
    ->get();

// Correct
return Project::active()
    ->orderBy('name')
    ->get();

For simple conditional Eloquent filters, keep the query as a fluent chain and use when() instead of creating a mutable $query variable and applying filters through separated if blocks.
Use long-form function closures in when() callbacks, following the PHP closure rule. Use a separate $query variable only when the conditional composition is too complex for a readable when() chain, when multiple branches return materially different query shapes, or when an external API requires a builder instance.

// Wrong
$query = Project::active();

if ($search) {
    $query->where('name', 'like', "%{$search}%");
}

if ($planId) {
    $query->where('plan_id', $planId);
}

return $query->orderBy('name')->get();

// Correct
return Project::active()
    ->when($search, function ($query) use ($search) {
        $query->where('name', 'like', "%{$search}%");
    })
    ->when($planId, function ($query) use ($planId) {
        $query->where('plan_id', $planId);
    })
    ->orderBy('name')
    ->get();

Reassigning the variable is the same violation as mutating it. $query = $this->applyFilters($query); inside an if still parks the chain in a variable, and when() still says it in one expression — the callback receives the builder and may return it.

// Wrong
$query = $query->with('roles');

if (billingCoreAvailable()) {
    $query = app('billing-core.user-resource')->applyIndexQuery($query);
}

return $query->latest();

// Correct
return $query->with('roles')
    ->when(billingCoreAvailable(), function ($query) {
        return app('billing-core.user-resource')->applyIndexQuery($query);
    })
    ->latest();

Optional Package Access Goes Through a Helper

An optional package is reached through one project helper that returns the integration or null. That helper owns the availability check, so a caller cannot resolve the integration without it.
Do not resolve a string-keyed container binding at the call site, and do not pair a separate availability check with the resolution. When the check and the resolution are two expressions, a caller can check one thing and resolve another — checking that the core package is installed while resolving an adapter that has its own enabled flag. Nothing about that reads as wrong, which is why it should not be expressible.

// Wrong — the check and the binding can drift apart
if (billingCoreAvailable()) {
    app('billing-core.user-resource')->validateFeatures($object, $request);
}

// Correct — one helper, and the wrong check is not available to write
billingUser()?->validateFeatures($object, $request);
// app/helpers.php
function billingUser(): ?object
{
    if (! billingCoreAvailable() || ! config('billing-core.user_adapter.enabled', true)) {
        return null;
    }
    return app('billing-core.user-resource');
}

This is about string-keyed bindings for optional integrations. Resolving a concrete class from the container, as in app(StripeSyncService::class), is ordinary dependency resolution and is not this rule. Framework aliases such as app('config') or app('cache.store') are not this rule either.
The accessor may be a method rather than a global helper. A resolve that sits next to app()->bound('<the same binding>') and can answer null already keeps the check and the resolution together, which is what the rule asks for. A test asserting the container is wired names the binding for the same reason: the binding is the assertion.

Use of long-form function syntax

Always use long-form function syntax, never arrow functions.
Fail this rule only when the source code actually contains the fn ( or fn( token for an arrow function. If every closure uses function (...) { ... }, this rule passes.
If the file contains only function (...) { ... } closures, this rule must pass even when the closure is one line long or immediately chained in a collection pipeline.
Do not identify function ($item) { ... }, each(function ($item) { ... }), map(function ($item) { ... }), or any other long-form closure as an arrow function. Those are compliant long-form closures.
Do not fail with contradictory reasoning. The presence or absence of the literal fn token is the deciding evidence for this rule.

// Correct
->whereHas('plan', function ($query) {
    return $query->where('slug', 'free');
})

// Incorrect
->whereHas('plan', fn($q) => $q->where('slug', 'free'))

Query Application Tables Through Models

Application tables are queried through their Eloquent model, not DB::table(). DB::table() is acceptable only for complex queries (joins, raw SQL, updates, stored procedures) and inside migrations. If the table has no model, create one (or the analyzer warns you to). This rule runs on PHP files, excluding migrations.

Class Size

A production class stays under 300 lines. A class over 300 lines must be split by purpose — one feature per trait, service, or child component. There is no exception written into the file: a class kept whole on purpose records why in docs/{project}, because a comment above the declaration would be an explanation, and Comments Earn Their Place reports those. Classes between 250 and 300 lines warn: they are approaching the limit. A trait over 300 lines also warns: check whether its name groups too many concerns; a review may keep it as is.
Where the extracted code goes depends on the class type:

  • Livewire components: feature traits in app/Livewire/Traits/, or child components.
  • Models: feature traits (HasPlans, HasPrices).
  • Services: specialized services; code that is not domain logic (e.g. button labels and colors) leaves the service.
  • Console commands: delegate the machinery to a service; the command stays thin.

Helpers (files of standalone functions), migrations, tests, configuration, and route files are exempt: they do not measure a class.

Weblabor Logo Developed by Weblabor