- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your domain package has its own composer.json, no Laravel in require, and a test suite that runs green without the framework installed. Then a pull request adds one line above an entity: #[Table('orders')], with a use Illuminate\Database\Eloquent\Attributes\Table; at the top. The tests still pass. The class still loads. Nothing in PHP complains, because PHP never looks at that attribute class unless someone asks it to.
Laravel 13, released on March 17, 2026 with PHP 8.3 as the minimum, puts more of the framework's configuration into attributes. That makes this case worth understanding. The attribute syntax looks like a type reference, but the engine treats it as a lazy note, and the note can point at a class that doesn't exist.
What Laravel 13 moved into attributes
The Eloquent docs for 13.x now show model configuration as class attributes from Illuminate\Database\Eloquent\Attributes. #[Table] takes the table name plus named arguments such as key, keyType, incrementing, timestamps and dateFormat. #[Fillable] can replace the $fillable property, and the serialization docs show #[Hidden] and #[Visible] as alternatives to $hidden and $visible:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Model; #[Table('users', key: 'user_id')] #[Fillable(['name', 'email'])] #[Hidden(['password'])] class User extends Model { } The release notes describe the same direction elsewhere: #[Middleware] and #[Authorize] on controllers, #[Tries], #[Backoff] and #[Timeout] on jobs, and new attributes "across Eloquent, events, notifications, validation, testing, and resource serialization APIs."
On a class that extends Model, none of this changes your architecture. The extends already ties the class to Eloquent, and that tie is loud: without Laravel installed, PHP can't load the class at all. The quiet case is a class that doesn't extend anything from the framework but still carries one of its attributes. That class loads anywhere.
How PHP actually stores an attribute
The attributes RFC that shipped in PHP 8.0 made a deliberate choice. When PHP compiles your class, it resolves each attribute's name against your imports and stores the name and the argument expressions. It doesn't load the attribute class. It doesn't check that the arguments match a constructor. The RFC says validation "is only performed during ReflectionAttribute::newInstance()", that declaring the attribute class is "technically not required", and that a call to getAttributes() "never throws errors." (Compiler-level attributes that PHP itself defines are the exception; userland ones are never checked at compile time.)
The use line doesn't load anything either. The PHP manual on importing states that "importing is performed at compile-time." An import is a name alias. It is resolved without touching the autoloader.
The reflection chapter of the manual puts the split plainly: "Objects of the attribute class are instantiated only after calling ReflectionAttribute::newInstance(), ensuring that argument validation occurs at that point."
See it in a 20-line script
Save this as order.php in an empty directory. No vendor/, no Composer, no Laravel:
<?php namespace App\Domain; use Illuminate\Database\Eloquent\Attributes\Table; #[Table('orders')] final class Order { public function __construct(public readonly int $id) { } } $order = new Order(42); echo "Built order {$order->id}\n"; $ref = new \ReflectionClass(Order::class); $attr = $ref->getAttributes()[0]; echo $attr->getName(), "\n"; var_dump($attr->getArguments()); $attr->newInstance(); Read it top to bottom with the RFC rules in mind. The class compiles, because the attribute is stored as a name. new Order(42) works, because constructing the class never touches the attribute. getAttributes() returns a ReflectionAttribute without throwing, getName() gives back the fully qualified name the import resolved to, and getArguments() gives back ['orders']. Only the last line has to find the class, and with no autoloader that can supply it, it throws an Error. A user-contributed example on the newInstance() manual page shows the message shape: Attribute class "FakeAttribute" not found.
I haven't run this exact file myself, so treat the walkthrough as documented behaviour rather than captured output. Running php order.php on PHP 8.3 or later takes a few seconds and settles it for your version.
The point is the order of events. Every line that a unit test of Order would exercise succeeds. The one line that fails is the one only a framework calls.
Why the quiet coupling still costs you
The import is real. Your domain namespace now names a class from Illuminate\, and that has three practical effects even though the code runs.
First, the domain now carries persistence knowledge. #[Table('orders')] on an entity says which table stores it, and that is the thing hexagonal architecture moves into an adapter. The next person to rename the table edits a domain class.
Second, nothing at runtime will tell you. A domain test never reflects on attributes, so it passes with or without Laravel. And on a class that isn't a model, Laravel never reads the attribute either, so nothing calls newInstance(). The tag is dead metadata attached to a real import. Only something that reflects on every attribute would trip on it: a generic mapper, a serializer, or a later refactor that turns the class into a model.
Third, it spreads by copy and paste. Attributes are short and look harmless, and an AI coding agent that has just read three models full of #[Fillable] and #[Hidden] will reach for the same pattern on the next class it writes. The coupling arrives one attribute at a time, and no single diff looks like an architecture change.
You can't fix this with PHP's runtime, because PHP considers it correct. You fix it with static analysis that reads the AST, where the attribute name is sitting in plain sight.
A deptrac layer that blocks the import
Deptrac checks dependencies between layers you define. Its use analyser, on by default, "analyses file definitions for use statements", so the import line above is enough for it to see the dependency. Put the framework in its own layer with a classNameRegex collector, the domain in a directory layer, and allow the domain nothing:
deptrac: paths: - ./src layers: - name: Domain collectors: - type: directory value: src/Domain/.* - name: Framework collectors: - type: classNameRegex value: '#^Illuminate\\#' ruleset: Domain: ~ Add your Application and Infrastructure layers the same way, each with its own allow list. The Domain: ~ line is the dependency rule in one line: the domain may depend on no other layer. Deptrac also has an attribute collector that groups classes by the attribute they carry, which is useful for the reverse check, for example "everything tagged with a Doctrine Entity attribute belongs to the persistence layer."
A PHPStan rule that names the attribute
Deptrac tells you a layer was crossed. If you want the error to say "framework attribute on a domain class" and point at the exact line, write a custom PHPStan rule. It hooks the virtual InClassNode, which the PHPStan docs point you to when you need the class reflection, then walks the class AST for every attribute, including those on properties, methods and promoted constructor parameters:
<?php namespace App\PHPStan; use PhpParser\Node; use PhpParser\Node\Attribute; use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; /** @implements Rule<InClassNode> */ final class NoFrameworkAttributesInDomain implements Rule { public function getNodeType(): string { return InClassNode::class; } public function processNode(Node $node, Scope $scope): array { $class = $scope->getClassReflection()?->getName() ?? ''; if (!str_starts_with($class, 'App\\Domain\\')) { return []; } $attrs = (new NodeFinder())->findInstanceOf( $node->getOriginalNode(), Attribute::class, ); $errors = []; foreach ($attrs as $attr) { $name = $attr->name->toString(); if (!str_starts_with($name, 'Illuminate\\')) { continue; } $errors[] = RuleErrorBuilder::message( "Framework attribute #[{$name}] on {$class}." ) ->identifier('domain.frameworkAttribute') ->line($attr->getStartLine()) ->build(); } return $errors; } } Register it in phpstan.neon and make sure Composer's autoload-dev can find the class:
rules: - App\PHPStan\NoFrameworkAttributesInDomain Two choices worth adjusting. The namespace prefixes are hard-coded to keep the example short; in a real project, pass them through the constructor from a parameters: entry. And Illuminate\\ is only the start of the deny list: add Symfony\\Component\\, Doctrine\\ORM\\ or whatever your edge is built from.
In a standalone domain package with no Laravel installed, stock PHPStan already flags the attribute: level 0 reports unknown classes, and an attribute class it can't find is one of them. The custom rule is for the more common layout, an App\Domain folder inside the Laravel app. There the attribute class resolves, PHPStan's built-in checks pass, and nothing else complains.
Keep the attributes, move them
None of this argues against Laravel 13's attributes. On an Eloquent model in your infrastructure layer, #[Table] and #[Fillable] are a tidier way to write what $table and $fillable already said. The model is an adapter, and adapters are supposed to know about the framework.
The fix is placement. The domain Order stays a plain class. An EloquentOrder model in infrastructure carries #[Table('orders')], and a repository adapter maps between the two. The attribute still does its job, at the edge, and the deptrac or PHPStan check fails the build the day one drifts inward.
If this was useful
Lazy attributes are a small example of a bigger habit: the framework gets into your domain through doors that compile cleanly and pass every test. Decoupled PHP is the book I wrote about closing those doors in Laravel and Symfony codebases, with ports, adapters and the CI checks that keep the boundary honest after the first refactor.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.
![Laravel 13 Puts #[Table] on Your Models. PHP Won't Load That Class Until newInstance()](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcz4ytbajureytql7wwi4.jpg)

