---
title: "Architecture presets — Testing for Laravel | Roundly"
description: "Eleven one-line Pest arch presets — strict types, final by default, model and factory seams, dependency policy, vendor bans, facade-only models and more."
url: https://roundly-consulting.com/open-source/docs/testing-for-laravel/architecture-presets
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Testing for Laravel](https://roundly-consulting.com/open-source/docs/testing-for-laravel.md)

# Architecture presets

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

```php
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::factoriesResolveThroughSeam(string $factoriesDir, string $srcDir, string $seamDir = 'Support'); // opt-in
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::noVendorNamespace(string|array $prefixes, ?string $srcDir = null, array $ignoring = []); // no GuzzleHttp\…, App\…
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

| Preset | Rule — and the bug it prevents |
| --- | --- |
| `strictTypes` | Every file declares strict\_types=1, so a silent type coercion can’t slip in. |
| `finalByDefault` | Classes are final by default; abstract classes are excluded automatically (abstract final is a PHP fatal). Prevents accidental extension points. |
| `swappableModelsAreNotFinal` | Each 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. |
| `noLocalCryptoPrimitives` | No local crypto primitives (ArchPresets::CRYPTO\_PRIMITIVES — hash, hash\_hmac, hash\_pbkdf2, hash\_hkdf, openssl\_\*, sodium\_\*, random\_bytes, random\_int, base64\_\* and more). hash\_equals is deliberately allowed: it is PHP’s constant-time compare. hash\_hkdf is banned since 1.4.0 — move it to crypto-for-laravel’s Crypto::hkdf(), which takes the algorithm and the length explicitly. |
| `modelsResolveThroughSeam` | In a model’s static context, no static:: or self:: call that builds a query on the named class — query(), Model’s other entry points (all, with, on, onWriteConnection, destroy) and any builder method \_\_callStatic forwards, such as where(), create() or firstOrCreate() — and no new static; no new self anywhere in a model; swap config literals appear only inside the seam directory. That bypass once broke authorization. Since 1.5.0, outside the seam no relation may name a swappable model by its packaged class (belongsTo(Comment::class)) and no static call may start a query on it (Comment::query(), Comment::where(), Comment::withTrashed()). |
| `factoriesResolveThroughSeam` | Opt-in. Every factory under $factoriesDir whose model is swappable overrides modelName() to return that model’s seam (return LikeModel::class();). Without it, Like::factory() builds the packaged class after a host swapped it — on the packaged table, without the host’s casts, scopes or events. |
| `morphColumnsUseTheSeam` | No raw morphs(), nullableMorphs(), numericMorphs(), nullableNumericMorphs() 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. |
| `runtimeRequireIsWhitelisted` | composer.json require holds only php and the other Composer platform packages (ext-\*, lib-\*, composer-runtime-api, composer-plugin-api, php-64bit, …), illuminate/\*, laravel/\*, symfony/\* and roundly-consulting/\*, plus exact names in $alsoAllow. Every disallowed vendor is reported by name, and so is every $alsoAllow entry that forgives nothing — see Preset exemptions. |
| `noDebuggingLeftovers` | No dd, dump, ray, var\_dump or print\_r — and none of the helpers Laravel hangs on its own objects: ->dd(), ->ddRawSql(), ->dumpRawSql() — in any letter case (DD() runs dd()). 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, and so are new Dump(…) and Ray::make(). |
| `noVendorNamespace` | Nothing under $srcDir (default <cwd>/src) names a namespace equal to or under one of $prefixes — imports, fully-qualified and qualified names, resolved through the file’s namespace and imports. Catches what ->not->toUse() passes over: sibling vendor packages, vendors that aren’t installed and host names like App\\. |
| `modelsGoThroughTheFacade` | No 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 eight 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
<?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::noVendorNamespace(['GuzzleHttp', 'App']);
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:

```php
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:

```php
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, with one exception: new self always builds the packaged class, so it is flagged anywhere in a model. Inside an instance method, self::query() already builds for $this’s runtime class — the configured one — so it is correct and not flagged. Neither is a static call to Model’s own statics or the class’s own helpers — static::creating(), static::addGlobalScope(), self::saving(), the shape of every booted() hook. The ban also applies only to classes that extend Model.

## The packaged class stays inside the seam

A swappable model is one the host may replace with its own subclass through config (comments.model). Outside the seam, code must use the class the seam resolves — naming the packaged class builds, relates or queries the wrong one. comments 1.1.0 shipped two such bypasses under a green modelsResolveThroughSeam: CommentMention::comment() was belongsTo(Comment::class), and AssertsComments queried Comment::query(). Since 1.5.0 the preset catches both, and factoriesResolveThroughSeam closes the same hole in model factories:

```php
ArchPresets::modelsResolveThroughSeam(__DIR__.'/../src', 'Support', ['comments.model']);
ArchPresets::factoriesResolveThroughSeam(__DIR__.'/../database/factories', __DIR__.'/../src'); // opt-in
```

## Which models are swappable

Both presets read the swappable models from the package’s own seam, not from config: resolving config('comments.model') needs a booted app, and a naming convention would cover some seams and miss others. A model is swappable when a method under $seamDir is static, takes no parameters and declares a string return type, and its body names an Eloquent model by X::class beside a config key literal — ModelResolver::for('comments.model', Comment::class), config('x.model', X::class) or a hand-written read-then-is\_a check. The method can have any name (CommentModel::class(), TicketModels::ticket(), SluggableConfig::historyModel()), and a self::KEY or self::DEFAULT constant counts as the value it holds. An interface, Eloquent’s own Model, an instance method, a method with parameters and a method naming no key are not seams. Measured on 2026-10-17, the rule found exactly the models each of the 31 adopting packages lists in its swappableModelsAreNotFinal() map — 66 models.

What it can’t see: a seam that takes its key or default as an argument, reads them from another class or builds them at runtime. Declared $modelKeys entries turn that into a failure — each must be read by a seam method beside its default model. An undeclared, shape-inferred key isn’t held to this, because inference also matches keys that swap nothing the package ships, such as auth.providers.users.model. Declare the keys to make the rule apply.

## Relations and static calls on the packaged class

Outside $seamDir, for every swappable model, modelsResolveThroughSeam fails:

- A relation whose argument is the packaged class — belongsTo(Comment::class), hasMany(Comment::class, 'parent\_id'), a through model in hasManyThrough(Reply::class, Comment::class), belongsToMany(related: Comment::class), self::class inside Comment or parent::class inside a packaged subclass, through an alias or a fully-qualified name too. It covers belongsTo, hasOne, hasMany, belongsToMany, morphOne, morphMany, morphToMany, morphedByMany, hasOneThrough and hasManyThrough, and any top-level argument counts.
- A static call that starts a query on the packaged class — Comment::query(), all(), with(), destroy(), where(), find(), create(), firstOrCreate(), updateOrCreate(), count(), withTrashed(), onlyTrashed() or a local scope such as Comment::approved(). A call starts a query when Model’s \_\_callStatic forwards it — any method the model doesn’t declare — and Model’s own entry points query, all, with, on, onWriteConnection and destroy count too. When the model can’t be autoloaded, the preset falls back to the Eloquent and query builders’ public methods plus the soft-delete macros, minus what Model declares.

Allowed: Comment::class on its own (instanceof, is\_a(), a morph map, a policy map), a static the model declares (Comment::creating(), Comment::observe(), its own helper), static::class, anything inside $seamDir and any non-swappable model. Docblocks and strings never count. Use the seam instead:

```php
return $this->belongsTo(CommentModel::class(), 'comment_id');
$query = CommentModel::class()::query()->where('commentable_id', $id);
```

Each finding names the file, line and call — “These relations name a swappable model by its packaged class outside the 'Support' seam”, then - <path>:<line> belongsTo(Comment::class). A declared key read in the seam by no method that names its default model fails too. A package with no swappable model passes these two halves; the late-static-binding and stray-literal halves still run.

## factoriesResolveThroughSeam: factories build the configured model

An Eloquent factory builds its $model — the packaged class — unless modelName() says otherwise, so Comment::factory() creates a packaged Comment after a host swapped it, on the packaged table and without the host’s casts, scopes or events. The fix is one method:

```php
/** @extends Factory<Like> */
final class LikeFactory extends Factory
{
    protected $model = Like::class;

    public function modelName(): string
    {
        return LikeModel::class();
    }
}
```

A factory is any concrete class under $factoriesDir that extends Illuminate\\Database\\Eloquent\\Factories\\Factory, directly or through a base factory in the same directory. Its model is its protected $model = X::class; default; a factory with no $model whose modelName() returns a seam is read as building that seam’s model. A factory of a swappable model must declare a modelName() whose whole body is return <Seam>::<method>(); for that model — no override, returning $this->model or X::class, or returning another model’s seam fails, naming the seam to return. A modelName() shown only in a docblock doesn’t count, and factories of models nobody swaps aren’t checked.

It is opt-in rather than part of modelsResolveThroughSeam, which every adopting package picks up on its next run: when it landed, 58 factories in 24 packages lacked the override. Adopt it on a package whose factories build a swappable model, together with the fix. The case is named “preset: factories build swappable models through the Support seam”, and it can’t pass over nothing: a missing directory, a seam with no swappable model, a directory with no factory class, factories none of which builds a swappable model and a factory whose model it can’t read all fail.

## noVendorNamespace: a ban toUse() can’t express

\->not->toUse('GuzzleHttp') stays green in three cases, because Pest’s arch layer expands a name only into classes under an installed PSR-4 root at or above it, and keeps a dependency only when its symbol exists:

- A sibling package under a vendor prefix — GuzzleHttp\\Psr7 and GuzzleHttp\\Promise are separate packages with their own roots, so the bare rule stays green with use GuzzleHttp\\Psr7\\Utils; in src/.
- A vendor that isn’t installed — there is nothing to expand into.
- A host namespace — a package has no App\\ root, so ->not->toUse('App') checks nothing.

noVendorNamespace reads names from source tokens instead, so it catches all three:

```php
ArchPresets::noVendorNamespace(['GuzzleHttp', 'App'], __DIR__.'/../../src');
ArchPresets::noVendorNamespace('Mockery', __DIR__.'/../../src', [LegacyBridge::class]);
```

A name equal to a prefix, or under Prefix\\, fails wherever it appears in code: a use import (plain, aliased, grouped, use function, use const), a fully-qualified name (new \\GuzzleHttp\\Client, #\[\\Vendor\\Attribute\], extends, catch) or a qualified name resolved through an import (Psr7\\Utils after use GuzzleHttp\\Psr7;). A qualified name with no matching import resolves into the file’s own namespace, a trait use inside a class body is a reference, and braced and global namespaces are read. Prefixes match case-insensitively and end at a namespace separator, so Guzzle doesn’t match GuzzleHttp\\…. Comments, docblocks, string literals such as class\_exists('GuzzleHttp\\Client') and member names don’t count.

$prefixes takes one namespace or a list, with surrounding backslashes trimmed; $srcDir defaults to <cwd>/src. An $ignoring entry skips a file whose first declared class-like matches it exactly or as a namespace prefix, and is rot-checked like any other exemption. The case is named “preset: never references <prefixes>”, and each finding names the file, line and name:

```
These files reference a banned namespace (GuzzleHttp\):
  - Http/Client.php:12 GuzzleHttp\Psr7\Utils
Remove the dependency, or reach it through the API the package is allowed to depend on.
```

It can’t pass over nothing: a missing source directory, one with no PHP file and a prefix that covers the scanned code’s own namespace all fail, and an empty prefix list or a blank prefix throws InvalidArgumentException.

## 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/testing-for-laravel.md)
