← All writing

Laravel 13 PHP Attributes: A Practical Migration Playbook

Laravel 13 ships around 36 native PHP attributes that replace protected class properties across models, jobs, commands, and controllers. Here is a pragmatic, incremental approach to adopting them without rewriting the entire codebase at once.


Laravel 13 landed in March 2026 with a change that looks cosmetic until you think about what it replaces. PHP attributes can now express the same model metadata, job configuration, and console signatures that previously required an expanding set of protected array properties scattered across class definitions.

The framework does not force the change. Existing property-based configuration keeps working. The reason to care is not syntax preference — it is where the long-term signal lives. New first-party packages, official starter kits, and the Laravel documentation are all converging on attributes as the idiomatic form. Building new features in the old style means writing code that will read as a maintenance burden sooner than the previous transition did.

This is not a rewrite argument. It is an incremental adoption playbook.

What changed and what did not

Laravel 13 requires a minimum of PHP 8.3. PHP attributes have been available since PHP 8.0, but the framework itself only wired them into Eloquent, queue infrastructure, and the console in this release.

The runtime contract stayed identical. #[Fillable(['name', 'email'])] and protected $fillable = ['name', 'email'] compile to the same effective model configuration. The attribute is not magic — it is a structured annotation that the framework reflects on during boot.

What changed is the noise-to-signal ratio in class definitions:

// Before: Laravel 12 style
final class Invoice extends Model
{
    protected $fillable = ['number', 'status', 'customer_id', 'total_cents'];
    protected $hidden = ['internal_notes'];
    protected $casts = [
        'issued_at' => 'datetime',
        'paid_at'   => 'datetime',
        'total_cents' => 'integer',
    ];
}

// After: Laravel 13 attributes
use Illuminate\Database\Eloquent\Attributes\Cast;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;

#[Fillable(['number', 'status', 'customer_id', 'total_cents'])]
#[Hidden(['internal_notes'])]
#[Cast('issued_at', 'datetime')]
#[Cast('paid_at', 'datetime')]
#[Cast('total_cents', 'integer')]
final class Invoice extends Model {}

The class body is now empty. The declaration layer and the implementation layer do not share space.

Starting with new classes, not existing ones

The lowest-risk adoption path is a team policy: all new models, jobs, and commands use attributes; existing ones are migrated on contact.

“Migrated on contact” means that when you open a file to change its behaviour, you convert its properties to attributes in the same pull request. This spreads migration across normal work without creating a dedicated refactor sprint that touches hundreds of files simultaneously and dominates review queues.

For a greenfield model, the full attribute surface looks like this:

use Illuminate\Database\Eloquent\Attributes\Cast;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Table;

#[Table('payment_transactions')]
#[Fillable(['amount_cents', 'currency', 'status', 'reference'])]
#[Cast('processed_at', 'immutable_datetime')]
#[Cast('amount_cents', 'integer')]
final class PaymentTransaction extends Model {}

For a queue job, the same principle applies to infrastructure configuration:

use Illuminate\Queue\Attributes\Connection;
use Illuminate\Queue\Attributes\Queue;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Connection('redis')]
#[Queue('payments')]
#[Tries(3)]
#[Timeout(60)]
final class ProcessPaymentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public readonly int $transactionId,
    ) {}

    public function handle(): void
    {
        // business logic
    }
}

Controllers and middleware: the most visible change

Middleware declaration in controllers was always slightly awkward. The middleware() method called inside __construct felt like it belonged to routing, not to the class implementing the behaviour. Attributes move it to the declaration layer:

use Illuminate\Routing\Attributes\Middleware;

#[Middleware(['auth:sanctum', 'verified'])]
final class InvoiceController extends Controller
{
    public function index(): JsonResponse { /* … */ }

    #[Middleware('can:export-invoices')]
    public function export(): StreamedResponse { /* … */ }
}

Method-level attributes are particularly useful for permission-gating specific actions without constructing conditional middleware chains.

Console commands: signatures without the boilerplate

The protected $signature and $description properties on Artisan commands were always a slightly awkward fit for a framework that otherwise uses constructor injection. Attributes align console commands with the rest of the class declaration style:

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;

#[Signature('invoices:archive {--months=3 : Archive invoices older than this many months}')]
#[Description('Archive old invoices and release their storage.')]
final class ArchiveInvoicesCommand extends Command
{
    public function handle(): int
    {
        $months = (int) $this->option('months');
        // …
        return Command::SUCCESS;
    }
}

Verification step: type-checking still works

Because attributes are native PHP 8 syntax, pnpm astro check (or, in the Laravel context, php artisan ide-helper:generate) does not require changes. PHPStan, Larastan, and Rector all understand PHP attributes natively. Static analysis on attribute-annotated classes is identical to property-annotated ones.

Run php artisan config:clear and php artisan optimize:clear after migrating a model to ensure no cached class configuration interferes with the new attribute resolution during development.

A note on team readability

One friction point worth naming: developers who have only worked with property-based Laravel will need a short orientation. Attributes resolve at the class level using PHP reflection, which is not obvious from reading the syntax alone. A brief ADR or team wiki entry explaining “we use PHP attributes in Laravel 13” and linking to the official documentation prevents the question from surfacing in every code review for the first three months.

The practical migration checklist

Before shipping a PR that converts class properties to attributes on an existing model or job:

[ ] PHP 8.3+ confirmed in composer.json require-dev and production Dockerfile
[ ] Import statements added for each attribute class
[ ] Protected properties removed (not just commented out)
[ ] Static analysis passes: php artisan analyse
[ ] Feature tests covering the model's fillable and cast behaviour still pass
[ ] No cached config reflects old property values: php artisan optimize:clear
[ ] PR description notes this is a structural refactor, not a behaviour change

The migration across a large codebase will take months if done incrementally. That is the point. Attributes are not urgent, and urgency is what turns a straightforward modernisation into an incident.