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
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 |
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.
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.
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.