NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Testing for Laravel

Architecture presets

Nine composable presets, each grounded in a bug that shipped. Call one at the top level of a Pest arch file; it registers its own case. Bind the arch file to your PackageTestCase-based TestCase — uses(TestCase::class)->in('Arch', …) in tests/Pest.php, as in Installation: swappableModelsAreNotFinal reads the config default from the booted app and fails with instructions when there is none.

use RoundlyConsulting\Testing\Arch\ArchPresets;

ArchPresets::strictTypes(string $namespace, array $ignoring = []);
ArchPresets::finalByDefault(string $namespace, array $ignoring = []);
ArchPresets::swappableModelsAreNotFinal(array $map);               // [Shop::class => 'shops.shop_model']
ArchPresets::noLocalCryptoPrimitives(string $namespace, array $ignoring = []);
ArchPresets::modelsResolveThroughSeam(string $srcDir, string $seamDir = 'Support', array $modelKeys = []);
ArchPresets::morphColumnsUseTheSeam(string $migrationsDir);        // no raw $table->morphs()
ArchPresets::runtimeRequireIsWhitelisted(string $composerJson, array $alsoAllow = []);
ArchPresets::noDebuggingLeftovers(array $ignoring = [], ?string $srcDir = null); // dd/dump/ray/var_dump/print_r, ->dd()
ArchPresets::modelsGoThroughTheFacade(string $namespace, array $ignoring = []); // models/traits never call actions

// Helpers the presets build on
ArchPresets::shadowedClassesAreFinal(string $namespace, array $exemptions); // only if you use ->ignoring()
ArchPresets::exemptionsExist(array $exemptions, string $for, ?string $within = null); // the pin the presets register for you

What each one guards

PresetRule — and the bug it prevents
strictTypesEvery file declares strict_types=1, so a silent type coercion can’t slip in.
finalByDefaultClasses are final by default; abstract classes are excluded automatically (abstract final is a PHP fatal). Prevents accidental extension points.
swappableModelsAreNotFinalEach mapped model is non-final and its config key defaults to that very model. final on a swappable model was a PHP fatal the moment a host swapped it — shipped seven times.
noLocalCryptoPrimitivesNo local crypto primitives (ArchPresets::CRYPTO_PRIMITIVES — hash, hash_hmac, openssl_*, sodium_*, random_bytes, random_int, base64_* and more). hash_equals is deliberately allowed: it is PHP’s constant-time compare.
modelsResolveThroughSeamNo static::query(), self::query() or new static in a static context of a Model, and swap config literals appear only inside the seam directory. That bypass once broke authorization.
morphColumnsUseTheSeamNo raw morphs(), nullableMorphs() or their uuid/ulid variants in a migrations directory — morph columns must go through the package toolkit’s morphKey() macro. A missing or empty directory fails.
runtimeRequireIsWhitelistedcomposer.json require holds only php, ext-*, illuminate/*, laravel/*, symfony/* and roundly-consulting/*, plus exact names in $alsoAllow. Every disallowed vendor is reported by name.
noDebuggingLeftoversNo dd, dump, ray, var_dump or print_r — and none of the helpers Laravel hangs on its own objects: ->dd(), ->ddRawSql(), ->dumpRawSql(). Scanned from source tokens under <cwd>/src by default, so ray() is caught even when the Ray debugger package isn’t installed. A chained ->dump() is deliberately allowed, since $yaml->dump() is ordinary API.
modelsGoThroughTheFacadeNo model method or model trait references the package’s Actions namespace, so every mutation goes through the manager and the facade’s fake() sees it — see Facade contract.

strictTypes, finalByDefault and noLocalCryptoPrimitives are built on Pest’s arch layer and return the arch expectation. The other six express what Pest’s arch layer can’t and register a token/reflection it() case instead.

Pest’s own arch case passes over an empty set, so each of those three namespace-scoped presets also registers a companion case — “preset: … has something to check” — that fails when the namespace resolves to nothing through Composer’s PSR-4 map (one mistyped letter) or when $ignoring exempts everything in it.

A typical arch file

<?php

declare(strict_types=1);

use RoundlyConsulting\Shops\Exceptions\ShopsException;
use RoundlyConsulting\Shops\Shops\Shop;
use RoundlyConsulting\Testing\Arch\ArchPresets;

ArchPresets::strictTypes('RoundlyConsulting\Shops');

// Exempt the intentional extension points through the checked parameter…
ArchPresets::finalByDefault('RoundlyConsulting\Shops', [Shop::class, ShopsException::class]);

// …and pin the swappable model with the counter-weight preset.
ArchPresets::swappableModelsAreNotFinal([Shop::class => 'shops.shop_model']);

ArchPresets::noLocalCryptoPrimitives('RoundlyConsulting\Shops');
ArchPresets::modelsResolveThroughSeam(__DIR__.'/../src', 'Support');
ArchPresets::morphColumnsUseTheSeam(__DIR__.'/../database/migrations');
ArchPresets::runtimeRequireIsWhitelisted(__DIR__.'/../composer.json');
ArchPresets::noDebuggingLeftovers();
ArchPresets::modelsGoThroughTheFacade('RoundlyConsulting\Shops');

The deliberate tension

finalByDefault wants every class final; swappableModelsAreNotFinal forbids final on a config-swappable model. That tension is on purpose — run both: exempt the swappable models from the first through $ignoring and pin them with the second. An empty map fails rather than passing vacuously. The same check is available per model as an expectation and as a static mirror:

expect(Shop::class)->toBeSwappableVia('shops.shop_model');

// The same check for plain PHPUnit
Assert::modelIsSwappableVia(Shop::class, 'shops.shop_model');

All three read the config default, so they need the booted app — bind the file to your PackageTestCase-based TestCase.

Declaring model keys for modelsResolveThroughSeam

Undeclared, the stray-literal half infers swap keys from their shape — model, models or *_model — and polices only those. If any swap key you own is shaped differently, declare the list. Declared keys are unioned with the inferred ones, and a declared key that matches no literal in your source fails:

ArchPresets::modelsResolveThroughSeam(__DIR__.'/../src', 'Support', [
    'alerts.alert', 'alerts.health-check', 'alerts.silence-model', 'alerts.history.model',
]);

The late-static-binding half fires only in a static context. Inside an instance method, self::query() already builds for $this’s runtime class — the configured one — so it is correct and not flagged. The ban also applies only to classes that extend Model.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.