Laravel 13 Native Attributes: Slimmer Models and a Cleaner Codebase | Deploynix Laravel Blog
Back to Blog

Laravel 13 Native Attributes: Slimmer Models and a Cleaner Codebase

Sameh Elhawary · · 19 min read
Laravel 13 Native Attributes: Slimmer Models and a Cleaner Codebase

Open the biggest model in your app right now. Ours was Server.php, and before we refactored it this spring, the first 60 lines were pure configuration: a $table override from an old naming decision, a 14-entry $fillable array, $hidden for two token columns, a casts() method with nine entries, and a $touches array nobody had questioned since 2024. Sixty lines before the first line of actual behavior. Every Laravel developer has a model like this, and most of us stopped seeing the clutter years ago.

Laravel 13, released on March 17, 2026, finally gives that clutter a proper home. The framework now supports native PHP attributes in more than 15 locations, replacing property declarations like $table, $hidden, and $fillable on models and extending the same treatment to event listeners, notifications, mailables, and broadcast events (Laravel News, 2026). Crucially, the release ships with no app-code breaking changes: your property-based models keep working exactly as before (KrishaWeb, 2026).

We've now migrated most of the Deploynix codebase to the attribute style, and we deploy Laravel apps for a living, so we've also watched dozens of customer apps make the same transition. This post covers what attributes actually are, why the old property-based configuration aged badly, a full tour of the new surface with before/after code, and a migration plan that won't wreck your sprint. If you're still planning the version jump itself, start with our guide to upgrading to Laravel 13 in production with zero downtime.

Key Takeaways

- Laravel 13 (March 17, 2026) adds native PHP attributes to 15+ framework locations, with PHP 8.3 as the minimum version (Laravel News, 2026) - Property-based config still works; there are no app-code breaking changes - Attributes are reflection-read and cached, so runtime cost is effectively zero - Migrate incrementally, one namespace at a time, with tests and small deploys

What Are PHP Attributes, and Why Did Laravel Adopt Them Now?

PHP attributes are structured metadata you attach to classes, methods, and properties, introduced in PHP 8.0 back in 2020. The engine parses them at compile time and exposes them through reflection. Laravel 13 leans on them across 15+ framework locations, a move made practical by the framework's new PHP 8.3 minimum requirement (PHP Everyday, 2026).

If you've written a #[Test] annotation in PHPUnit or a #[Route] in Symfony, you already know the shape. An attribute is a small, dedicated class. When you write #[Table('legacy_servers')] above a model, you're instantiating real, typed, autoloadable code that your IDE can resolve, your static analyzer can inspect, and the framework can read through reflection.

That last part matters more than it sounds. Laravel has always been a convention-driven framework, and conventions used to live in loosely typed protected properties and magic strings. Attributes let the same conventions live in declared, discoverable classes instead. Nothing about Eloquent's behavior changed in Laravel 13. What changed is where the configuration lives and how much of it your tooling can actually see.

Laravel didn't invent this pattern overnight, either. The framework had been testing the water for two major versions: #[ObservedBy] and #[ScopedBy] on models, attribute-based route model binding tweaks, and container contextual attributes all landed earlier. Laravel 13 is the point where the pattern went from a garnish to the recommended default. And with PHP 8.5 now stable, the language side of this story is thoroughly settled.

Why Did Property-Based Configuration Age So Badly?

The short answer: property config scattered one model's identity across five untyped declarations, and tooling could never fully understand any of them. Laravel 13's attribute push exists precisely because the framework team saw the same friction at scale across 15+ configuration points (Laravel News, 2026). Three specific problems kept biting us.

First, magic strings everywhere. protected $table = 'legacy_servers' is a string the framework interprets at runtime. So is every entry in $fillable, $hidden, $appends, and casts(). Rename a column, and your IDE's refactoring tools skip every one of those strings. We once shipped a bug where a renamed ip_address column stayed in $fillable as the old name for three weeks. Nothing errored. Mass assignment just silently dropped the field.

Second, IDE blindness. A protected array property has no schema. Your editor can't tell you that 'datetime' is a valid cast and 'date_time' isn't, because both are just strings in an array. Attribute classes have constructors with typed parameters, so autocomplete, go-to-definition, and inline docs all work the way they do for normal code.

Third, scattered conventions. In a ten-person team, one developer alphabetizes $fillable, another groups it by feature, a third switched that model to $guarded = [] in 2023, and a fourth added a casts() method below 200 lines of scopes. None of these choices is wrong. Collectively, they mean no two models read the same way. Attributes don't magically enforce consistency, but they give configuration one canonical position, at the top of the class, in a syntax that linters can actually check.

Was the old style unworkable? No. Plenty of successful apps will keep it for years. But if you've ever scrolled past 60 lines of arrays to find a relationship method, you know the ergonomic ceiling.

A Tour of the Laravel 13 Attribute Surface

The rest of this section walks through the four areas where attributes change day-to-day code the most: models, event listeners, notifications and mailables, and broadcast events. All of the "before" examples remain fully valid in Laravel 13, since the release intentionally avoided app-code breaking changes (KrishaWeb, 2026).

Models: Table, Fillable, Hidden, and Casts

Here's a trimmed version of our real Deployment model, before and after. First, the Laravel 12 style:

class Deployment extends Model
{
    use HasFactory;

    protected $table = 'site_deployments';

    protected $fillable = [
        'site_id',
        'server_id',
        'commit_sha',
        'branch',
        'status',
        'triggered_by',
    ];

    protected $hidden = [
        'deploy_token',
    ];

    protected function casts(): array
    {
        return [
            'status' => DeploymentStatus::class,
            'is_rollback' => 'boolean',
            'finished_at' => 'datetime',
        ];
    }
}

And the Laravel 13 attribute equivalent:

use Illuminate\Database\Eloquent\Attributes\Casts;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\Table;

#[Table('site_deployments')]
#[Fillable(['site_id', 'server_id', 'commit_sha', 'branch', 'status', 'triggered_by'])]
#[Hidden(['deploy_token'])]
#[Casts(['status' => DeploymentStatus::class, 'is_rollback' => 'boolean', 'finished_at' => 'datetime'])]
class Deployment extends Model
{
    use HasFactory;
}

The class body now contains only behavior: relationships, scopes, business methods. Configuration sits above the class name where you read it once and move on. Here's how the common model properties map:

Laravel 12 property

Laravel 13 attribute

Notes

protected $table

#[Table('...')]

Class-level, single value

protected $fillable

#[Fillable([...])]

Same array semantics

protected $guarded

#[Guarded([...])]

Mutually exclusive with Fillable, as before

protected $hidden

#[Hidden([...])]

Serialization only, unchanged behavior

casts() method

#[Casts([...])]

Complex closures stay in the method

protected $touches

#[Touches([...])]

Same relation-name strings

protected $with

#[With([...])]

Eager-load defaults

One honest caveat: casts that need runtime logic, like a cast that varies by tenant, still belong in the casts() method. Attributes are static metadata. In our experience that covers maybe 5 percent of casts, and mixing the two on one model works fine.

Event Listeners: Retiring the EventServiceProvider Wiring

Event discovery has been drifting away from manual registration since Laravel 11, the same release cycle that started deprecating app/Http/Kernel.php, which Laravel 13 has now removed entirely (PHP Everyday, 2026). Attributes finish the job for listeners. Before, the mapping lived in a provider, far from the listener itself:

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        DeploymentFinished::class => [
            NotifyTeamOfDeployment::class,
            RecordDeploymentMetrics::class,
        ],
    ];
}

In Laravel 13, the listener declares its own subscription, including queue configuration:

use Illuminate\Events\Attributes\ListensTo;
use Illuminate\Queue\Attributes\OnQueue;

#[ListensTo(DeploymentFinished::class)]
#[OnQueue('notifications')]
class NotifyTeamOfDeployment implements ShouldQueue
{
    public function handle(DeploymentFinished $event): void
    {
        // ...
    }
}

This is the change we'd rank highest for maintainability. When wiring lived in the provider, deleting a listener class without cleaning the provider produced a runtime error in production, not a static one. With the attribute, the listener is self-describing: open the file, and you know what it listens to and which queue it runs on. Nothing to keep in sync.

Notifications and Mailables

Notifications got the same treatment for channel routing. The old via() method returning an array of strings is exactly the kind of stringly-typed convention attributes were built to replace:

// Before: Laravel 12
class DeploymentFailedNotification extends Notification implements ShouldQueue
{
    public function via(object $notifiable): array
    {
        return ['mail', 'slack', 'database'];
    }
}

// After: Laravel 13
#[Channels(['mail', 'slack', 'database'])]
class DeploymentFailedNotification extends Notification implements ShouldQueue
{
    // via() no longer needed for static channel lists
}

Dynamic routing, where channels depend on the notifiable's preferences, keeps the via() method. The attribute covers the static case, which for most apps is the overwhelming majority. Mailables similarly move envelope metadata like subject lines and reply-to addresses into #[Envelope] attribute parameters, leaving the class body to focus on content building. If your app leans hard on notifications, this refactor pairs well with the queue and worker tuning items in our list of 20 quick performance wins for production Laravel apps.

Broadcast Events

Broadcast events previously mixed metadata into methods: broadcastOn(), broadcastAs(), and a class body that was mostly ceremony. Laravel 13 lets simple cases collapse:

// Before: Laravel 12
class ServerMetricsUpdated implements ShouldBroadcastNow
{
    public function broadcastOn(): array
    {
        return [new PrivateChannel('servers.'.$this->server->id)];
    }

    public function broadcastAs(): string
    {
        return 'metrics.updated';
    }
}

// After: Laravel 13
#[BroadcastOn('private:servers.{server}')]
#[BroadcastAs('metrics.updated')]
class ServerMetricsUpdated implements ShouldBroadcastNow
{
    public function __construct(public Server $server) {}
}

The channel template syntax resolves {server} against the event's public properties. For our Reverb-powered dashboards, this cut most broadcast event classes roughly in half. Events with genuinely dynamic channel logic keep their methods, same story as casts and notification channels.

Should You Go Attribute-First, Stay Property-Based, or Run Mixed?

Yes, mixed is fine, and it's how nearly everyone will actually live for the next year or two. Laravel 13 guarantees both styles work side by side with no breaking changes, and even a single class can combine an attribute for $table with a casts() method (KrishaWeb, 2026). Still, it helps to pick a direction deliberately.

Attribute-First

Strengths: Configuration is typed, discoverable, and consistently positioned. IDE autocomplete and go-to-definition work on config. Listener wiring is self-documenting. New classes are visibly slimmer, often by 30 to 60 lines on large models.

Best for: Actively developed apps already on Laravel 13, teams that generate new models and listeners weekly, codebases with heavy static analysis investment (PHPStan or Psalm in CI).

Considerations: Requires PHP 8.3 or newer, and every developer needs a short adjustment period. Dynamic config still needs methods, so you'll never be 100 percent attributes. Older tutorials and Stack Overflow answers will show the property style for years.

Property-Based (Status Quo)

Strengths: Zero migration effort. Every Laravel developer on Earth reads it fluently. Fifteen years of documentation, packages, and examples assume it. Fully supported in Laravel 13 with no deprecation warnings.

Best for: Small stable apps in maintenance mode, teams mid-upgrade with bigger problems to solve first, packages that must support Laravel 12, which remains an actively supported release.

Considerations: You keep every ergonomic problem described above: magic strings, IDE blindness, scattered conventions. New hires arriving from attribute-first codebases will find it dated. The framework's documentation now leads with attributes, so the property style slowly becomes the "legacy" path in docs and examples.

Mixed, With a Convention

Strengths: Lets you migrate incrementally with no big-bang risk. New code gets the modern style immediately, and old code migrates when touched.

Best for: Most real production teams, honestly. Anyone with more than 50 models.

Considerations: Without a written convention it decays into inconsistency, the exact disease you're treating. Write the rule down: for us it's "new classes use attributes; any model you materially edit gets converted in the same PR; no drive-by conversions in unrelated PRs." That last clause keeps diffs reviewable.

Rector deserves a mention here. The Rector Laravel rule set gained attribute-migration rules shortly after release, and they handle the mechanical 90 percent: $table, $fillable, $hidden, static casts, and listener wiring. We ran it namespace by namespace rather than repo-wide, reviewed each diff by hand, and caught two models where $guarded and $fillable had somehow coexisted for years. The tool converts syntax; it can't convert judgment, so treat its output as a draft.

What About Static Analysis and IDE Support?

This is where attributes quietly pay for the whole migration. Because an attribute is a real class with a real constructor, #[Casts(['status' => DeploymentStatus::class])] gives your tooling a ::class constant it can verify, rename, and track for usages. The string 'status' is still a string, but PHPStan's Laravel extension can now cross-check attribute arrays against your migrations and model doc-blocks far more reliably than it ever could with protected properties inherited through Eloquent's magic.

Concretely, here's what improved for us after converting: renaming a cast enum class updates the attribute automatically via IDE refactoring. Find-usages on an event class now surfaces every #[ListensTo] declaration, which made an event-flow audit take an afternoon instead of a week. And PHPStan flags a typo'd cast type at level 6, where before it sailed through at max level because arrays of strings carry no schema.

PhpStorm's 2026.1 release and the Laravel Idea plugin both shipped completion and inspections for the new attributes within weeks of the March release. If your team's editor situation is more varied, the fallback story is still fine, since attributes are plain PHP syntax that highlights correctly everywhere. The one rough edge we hit: some older code-generation tools still scaffold property-style models, so check your stubs. php artisan stub:publish and a ten-minute edit brings your make:model output into the attribute era.

Do Attributes Cost Anything at Runtime?

No, and this deserves a direct answer because "reflection" makes performance-minded engineers flinch. Laravel reads attribute metadata through reflection once, then caches the result in the same metadata caches that already store property-based configuration. The framework team confirmed there's no measurable runtime difference between the two styles (Laravel News, 2026).

We verified this ourselves anyway, because trust is good and benchmarks are better. On our own API, p50 response times before and after converting roughly 80 models were identical within noise, under half a millisecond of variance across a week of production traffic on both sides of the deploy. With opcache and Laravel's bootstrap caching in play, attribute reflection happens at cache-build time, not per request.

There's one operational implication worth knowing: because attribute metadata participates in framework caching, your deploy pipeline should run the standard cache rebuild steps, php artisan optimize or the equivalent, on every release. If your pipeline dates from a couple of major versions ago, our rundown of Laravel 12's deployment changes covers the cache and Kernel-removal groundwork that Laravel 13 builds on, since app/Http/Kernel.php had been deprecated since Laravel 11 and is now gone entirely (PHP Everyday, 2026).

When Is the Refactor Not Worth It?

Skip it when the codebase is small, stable, and rarely touched. A 15-model internal tool that gets two commits a quarter gains nothing from a mechanical rewrite, and every line you change is a line you can break. Laravel 13 supports the property style indefinitely with no deprecation timeline announced, so "never" is a legitimate migration date (KrishaWeb, 2026).

We'd also hold off in three other situations. If you're mid-upgrade and not yet stable on Laravel 13, finish the upgrade first; mixing a version jump with a style migration doubles your rollback ambiguity when something breaks. If your test coverage on models and listeners is thin, write the tests first, because a mechanical refactor without a safety net is just risk with extra steps. And if you maintain a package supporting both Laravel 12 and 13, stay property-based until you drop 12, which is still a supported release and will be for a while.

Ask one question: will a human edit this code in the next year? If yes, the refactor buys you compounding readability every time someone opens the file. If no, leave it alone. Refactoring is an investment, and investments need a return window. The broader trend data in our state of Laravel deployment in 2026 report suggests most actively developed apps hit that bar easily, while a long tail of maintenance-mode apps never will, and that's fine.

A Pragmatic Migration Plan for a Production Codebase

Here's the plan we used on our own app and now recommend to customers. Total elapsed time for us: about three weeks of background work, never blocking feature development, across roughly 80 models, 60 listeners, and 40 notifications.

Phase

Scope

Safety net

Deploy size

1

One low-risk model namespace (e.g. App\Models\Billing)

Existing feature tests + serialization snapshot tests

Single small deploy

2

Remaining model namespaces, one per PR

Rector draft, hand review, full suite per PR

One namespace per deploy

3

Listeners + EventServiceProvider teardown

Event fake assertions on every event/listener pair

Two deploys

4

Notifications, mailables, broadcast events

Notification fakes + a staging click-through

Two or three deploys

5

Stub updates + written convention in CONTRIBUTING

CI lint rule rejecting new property-style config

One deploy

Three principles make this boring, in the good sense. First, one namespace at a time. A repo-wide PR touching 200 files is unreviewable, and unreviewable PRs are where mechanical refactors go wrong. A 12-file PR gets real review in ten minutes.

Second, tests are the contract. Before converting a model, we made sure something asserted its mass-assignment behavior and its serialized shape, because $fillable and $hidden mistakes are silent by design. A cheap trick: a snapshot test that serializes one factory instance of every model and diffs the keys. It caught our only real conversion bug, a Hidden attribute that listed api_token while the property had said api_token_hash.

Third, deploy in small batches, quickly. Don't let five converted namespaces pile up unreleased. Each phase should reach production within a day of merging, so if anything slips through, the suspect list is one small diff, not three weeks of them. This is standard advice for any refactor, but doubly so for one that changes how configuration is read rather than what it says.

Shipping the Refactor on Deploynix

We ran this exact migration on Deploynix itself, using Deploynix, so here's the concrete workflow rather than the theory. Nothing below is specific to attributes; it's how we ship any large mechanical refactor where the blast radius is "every model in the app."

Each phase branch went through our staging-to-production pipeline. A push to the branch triggered a staging deploy via the GitHub integration, and staging ran the same PHP version as production, pinned per site, which mattered during the window when production was on PHP 8.3 and we were validating 8.5. On staging we ran the full Pest suite plus the serialization snapshot check against a production-shaped database, then let the branch soak for a few hours while monitoring watched error rates and queue depth.

Production deploys used the standard release model: each deploy builds into a fresh release directory, runs composer install, php artisan optimize, and migrations, and then goes live through an atomic symlink switch. Zero downtime, and critically for a refactor like this, the previous release stays on disk untouched. When our Phase 3 deploy surfaced a listener that Rector had converted but whose queue name attribute pointed at a queue Horizon wasn't consuming, we rolled back with one click, the symlink flipped to the prior release, and total customer-facing weirdness lasted under a minute. We fixed the queue name, redeployed, done. That failure would have been a tense evening with a rsync-based deploy; with releases and instant rollback it was a Slack message. The mechanics are the same ones we describe in how to roll back a failed deployment in 30 seconds.

The lesson we'd generalize: large mechanical refactors are only as scary as your rollback story. If reverting is instant and boring, you can ship a 12-file conversion PR every afternoon without ceremony, and the whole migration becomes background noise. If reverting is a 40-minute manual procedure, you'll batch changes into big risky releases, which is exactly backwards. Get the deployment mechanics right first, whatever platform or scripts you use, and the refactor itself becomes the easy part.

FAQ

Do I have to migrate to attributes when upgrading to Laravel 13?

No. Laravel 13 ships with no app-code breaking changes, and property-based configuration like $fillable and $table continues to work with no deprecation warnings (Laravel News, 2026). The upgrade and the refactor are separate projects. Do the upgrade first, stabilize, then migrate styles at your own pace.

Can attributes and properties coexist in the same model?

Yes, and mixing is officially supported. A model can use #[Table] and #[Fillable] while keeping a casts() method for dynamic casts (KrishaWeb, 2026). If both styles define the same setting, the attribute wins, but we'd treat that situation as a bug and let a lint rule catch it.

What's the minimum PHP version for Laravel 13's attributes?

Laravel 13 requires PHP 8.3 or newer (PHP Everyday, 2026). The attribute syntax itself dates back to PHP 8.0, so the constraint is the framework's floor, not the language feature. With PHP 8.5 now stable, we'd provision new servers on 8.4 or 8.5 and pin per site.

Do attributes slow down requests since they use reflection?

No. Attribute metadata is read via reflection once and cached alongside Laravel's existing bootstrap and metadata caches, so per-request cost is effectively zero (Laravel News, 2026). Our own production benchmarks showed no measurable p50 difference across 80 converted models. Just keep php artisan optimize in your deploy pipeline.

Can Rector automate the conversion?

Mostly. Rector's Laravel rule set converts the mechanical cases: $table, $fillable, $hidden, static casts, and listener registrations. Treat its output as a draft, review every diff, and leave dynamic configuration, like tenant-dependent casts or preference-based notification channels, in methods where it belongs. Run it per namespace, not repo-wide.

Where to Go From Here

Attributes won't change what your app does. They change how quickly the next engineer understands it, and over a codebase's lifetime that's the cost that dominates. Laravel 13 made the modern style available everywhere without forcing anyone's hand, which is exactly the right way to evolve a 15-year-old framework. Our advice: don't schedule a heroic rewrite. Pick one quiet model namespace this sprint, convert it behind your test suite, ship it in a small deploy, and let the pattern spread PR by PR from there.

If you want the fuller operational picture first, from server provisioning through release strategy, our definitive guide to Laravel deployment in 2026 covers the pipeline foundations that make refactors like this one routine instead of risky. Start there, then go slim down that 60-line model header. It's been waiting long enough.

Ready to deploy your Laravel app?

Deploynix handles server provisioning, zero-downtime deployments, SSL, and monitoring — so you can focus on building.

Get Started Free No credit card required

Related Posts