dashworthy / pest-plugin-arch-idioms
Framework-idiom architecture expectations for Pest.
Package info
github.com/dashworthy/pest-plugin-arch-idioms
pkg:composer/dashworthy/pest-plugin-arch-idioms
Requires
- php: ^8.4
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/support: ^13.0
- nikic/php-parser: ^5.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin: ^4.0
- pestphp/pest-plugin-arch: ^4.0
Requires (Dev)
- illuminate/mail: ^13.0
- illuminate/notifications: ^13.0
- pestphp/pest-dev-tools: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 15:01:45 UTC
README
Framework-idiom architecture expectations for Pest, registered directly onto
pest-plugin-arch. No DSL, no selector grammar, no reporting layer of its own —
every verb composes with arch() chains you already write.
Install
composer require --dev dashworthy/pest-plugin-arch-idioms
Verbs
| Verb | Passes when |
|---|---|
toBeQueued() |
the class implements ShouldQueue (or ShouldQueueAfterCommit, which extends it) |
toBeQueuedAfterCommit() |
the class implements ShouldQueueAfterCommit specifically |
toBeSync() |
the class implements neither — a positive assertion that dispatch is deliberately synchronous |
toMatchTableName() |
an Eloquent model resolves the table its class name implies |
toGuardMassAssignment() |
a model declares $fillable or $guarded, or relies on the fully guarded default |
toDeclareNotificationChannels() |
via() exists and returns a non-empty channel list |
toUseMarkdownMailTemplates() |
content() names a markdown template rather than a plain view |
toAuthorizeWithGate($expectedAbility) |
authorize() checks Gate::allows() with an ability name — the one $expectedAbility derives from the class name, when given |
toUseDirectorySuffixes($suffixes, $beneath) |
a class in a module's directory, however deeply nested, ends in the suffix that directory calls for |
toMatchGateAbilities($directories, $alsoChecked) |
(on a list of permission names) every permission is checked by some Gate::allows(), and every checked ability is a permission |
toMatchGateAbilities() is the odd one out: it is an ordinary expectation, not an
arch one. Its subject is the list of permission names rather than a selection of
classes, so it reports every mismatch in one run.
Usage
Each verb is an ordinary arch expectation. Point a selector at the Laravel base class that defines the layer, and the verb checks every class that extends it — no fixture classes to write.
Notifications are queued and declare their channels
use Illuminate\Notifications\Notification; arch('every notification is queued and declares its channels') ->expect('App') ->classes() ->extending(Notification::class) ->toBeQueued() ->toDeclareNotificationChannels();
toBeQueued() passes for a notification that implements ShouldQueue (or
ShouldQueueAfterCommit, which extends it); toDeclareNotificationChannels()
passes when its via() returns a non-empty channel list.
Mailables render from markdown
use Illuminate\Mail\Mailable; arch('every mailable uses a markdown template') ->expect('App') ->classes() ->extending(Mailable::class) ->toUseMarkdownMailTemplates();
The verb recognises only the named-argument form new Content(markdown: '...').
A positional new Content('mail.welcome') is reported as a violation even when
it points at a markdown template.
Models guard mass assignment and match their table
use Illuminate\Database\Eloquent\Model; arch('every model is safe and conventional') ->expect('App') ->classes() ->extending(Model::class) ->toGuardMassAssignment() ->toMatchTableName();
toGuardMassAssignment() passes for a model that declares $fillable or
$guarded, or relies on the fully guarded default; toMatchTableName() passes
when the class name resolves the table Laravel would derive from it.
Form requests authorize through a gate
use Illuminate\Foundation\Http\FormRequest; arch('every form request authorizes through a gate') ->expect('App') ->classes() ->extending(FormRequest::class) ->toAuthorizeWithGate();
toAuthorizeWithGate() passes when authorize() calls Gate::allows() with a
string literal. Pass a closure to also pin which ability: it receives the
request's class name and returns the ability it should check, or null to
accept any.
use Illuminate\Support\Str; // StoreWidgetRequest must check 'widgets_store'. $ability = function (string $request): ?string { if (! preg_match('/^(Index|Show|Store|Update|Destroy)(\w+)Request$/', class_basename($request), $parts)) { return null; } return Str::snake(Str::plural($parts[2])).'_'.strtolower($parts[1]); }; arch('every form request checks the ability its name implies') ->expect('App\Http\Requests') ->classes() ->extending(FormRequest::class) ->toAuthorizeWithGate($ability);
Every permission is checked, and every check names a permission
use App\Models\Permission; test('permissions and gate checks agree', function () { expect(Permission::pluck('name')->all()) ->toMatchGateAbilities([app_path()], alsoChecked: ['viewPulse']); });
toMatchGateAbilities() reads every Gate::allows('...') call in the PHP files
beneath the directories, outside any vendor directory. It fails for a
permission nothing checks (dead weight) and for a checked ability no permission
backs (it can never be granted), listing every mismatch at once. Pass abilities
the source cannot show — names built at runtime, or checked by a package — as
$alsoChecked.
Modules keep to their approved directories
use App\Actions\Action; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Routing\Controller; use Laravel\Fortify\Contracts\ResetsUserPasswords; arch('every module keeps to the approved directories') ->expect('App\Domains') ->toUseApprovedDirectories( [ 'Actions' => fn (string $class): bool => is_a($class, Action::class, true) || is_a($class, ResetsUserPasswords::class, true), 'Data', 'Controllers' => Controller::class, 'Enums' => UnitEnum::class, 'Models' => Model::class, 'Requests' => FormRequest::class, 'Resources' => JsonResource::class, ], beneath: 'App\Domains\*\*', );
A directory keyed to a class or interface holds only classes that extend or
implement it. Where one type is not enough, key it to a closure instead: it is
given each class's name and returns true to accept it, or false or the
reason to reject it.
Classes are named for their directory
arch('every class is named for the directory it sits in') ->expect('App\Domains') ->toUseDirectorySuffixes( [ 'Actions' => 'Action', 'Controllers' => 'Controller', 'Requests' => 'Request', 'Resources' => 'Resource', ], beneath: 'App\Domains\*\*', );
It checks only the directories it lists, and a class nested deeper inside one
is held to the same suffix. Which directories may exist is left to
toUseApprovedDirectories().
Asserting a deliberate decision instead of ignoring it
A class caught by a selector that is meant to break the rule can be carved out
with ->ignoring(...):
arch('notifications are queued') ->expect('App') ->classes() ->extending(Notification::class) ->toBeQueued() ->ignoring(App\Notifications\PaymentDeclined::class);
But an exclusion only records that PaymentDeclined was skipped, not what it was
opted into. When a class is deliberately synchronous, assert that in place with
toBeSync() instead — the next reader sees the decision without hunting for the
class:
arch('the payment-declined alert is sent synchronously') ->expect(App\Notifications\PaymentDeclined::class) ->toBeSync();
What you are accepting
These are arch expectations, so they inherit arch's behaviour:
- One violation per run. A rule failing across forty classes reports the first and stops.
@pest-arch-ignore-linesuppresses any of them. Police its use with an arch test of your own if that matters to you.- An empty layer passes. A typo'd namespace turns a rule green. Assert the layer is non-empty separately if you need that guard.
Known limitations
Every verb that inspects a model instantiates it via
newInstanceWithoutConstructor(); no database connection or booted container
is needed, but anything Eloquent resolves during construction is invisible to
the check. That is the general form of the #[Fillable] caveat below.
toGuardMassAssignment()
- The verb produces a false failure on correct code that declares
#[Fillable([...])]or#[Guarded([...])]as a PHP attribute. It readsgetFillable()/getGuarded()from an instance created withnewInstanceWithoutConstructor(), but Eloquent only resolves the attribute forms insideinitializeGuardsAttributes(), which runs during construction. With the constructor skipped, an attribute-declared list is invisible and the model is reported as unguarded. This repository'sapp/Domains/Shared/Teams/Models/Membership.phpdeclares#[Fillable(['team_id', 'user_id', 'role'])]and is wrongly flagged by this verb. Exclude any model using the attribute form with->ignoring(...). - A model that
extends Pivotinheritsprotected $guarded = []fromIlluminate\Database\Eloquent\Relations\Pivot, so pivots are reported as unguarded unless they declare$fillablein property form. - A non-model class caught by the selector is reported as a violation, not
skipped. Scope the selector or use
->ignoring(...). - Abstract classes pass without being checked.
toMatchTableName()
- A model that does not set
$tablepasses by construction —getTable()derives exactly the name the verb expects, so the rule cannot fail for it. It only guards against a future$tablethat breaks convention; it is not a check on current code. - A legitimately custom table name (a pivot such as
team_members, or a legacy table) is a violation by design; exclude it with->ignoring(...). - Same non-model and abstract-class behaviour as
toGuardMassAssignment().
toDeclareNotificationChannels() and toUseMarkdownMailTemplates()
- Both read the class's own file. A
via()orcontent()provided by a trait is not seen — the AST inspected is the class's own file only — and is reported as missing. - Both read
$object->stmts, which is the AST for the whole file, not one class body. A second class (or an anonymous class) in the same file can satisfy the check on its neighbour's behalf — a false pass, not a false failure. Keep one class per file. toUseMarkdownMailTemplates()additionally scans every argument node insidecontent(), so a nested, unrelated call that happens to use amarkdown:named argument also produces a false pass. It also only recognises the named-argument form —new Content(markdown: '...'). A positionalnew Content('mail.welcome')is reported as violating even when it does point at a markdown template.
toBeQueued(), toBeQueuedAfterCommit(), toBeSync()
- No type guard at all: a trait, interface or enum caught by the selector is
reported as not implementing
ShouldQueue. Scope the selector.
toAuthorizeWithGate() and toMatchGateAbilities()
- Both read source text, not the AST, and see only
Gate::allows()called with a string literal.Gate::denies(),Gate::authorize(),$user->can(), policies,can:middleware and@canare not read, and an ability held in a variable or constant is invisible. Pass anything else that is checked through$alsoChecked. - Text that merely mentions a
Gate::allows('...')call with a quoted ability — a comment or docblock — is read as a check. toAuthorizeWithGate()readsauthorize()from the file that declares it, so an inheritedauthorize()is read from the parent. Abstract classes pass without being checked.toMatchGateAbilities()scans every.phpfile beneath the directories, which includes Blade views; it skips any path containing avendordirectory.
Editor and agent support
The package ships a Laravel Boost skill and guideline. Boost discovers them at
resources/boost/skills/ and resources/boost/guidelines/ for every installed
package, so a consuming project picks them up by running:
php artisan boost:update
The skill explains which verb covers which case and how to scope or exempt one; the guideline is the short form injected into the project's AI guidelines.