PHP Attributes on models, jobs, commands, and requests. Cache::touch(). No breaking changes. Published 12 days before launch so you’re ready on day one.
Laravel 13 releases March 17, 2026 — two weeks from now. The upgrade requires PHP 8.3 minimum (up from 8.2), but beyond that it’s a remarkably smooth release. No breaking changes to application code. Two headline features that quietly clean up patterns you write every single day.
This is the complete guide with real before/after code for every change. Bookmark it. You’ll want it on March 17.
What’s Changing in Laravel 13
Two features ship with Laravel 13:
- PHP Attributes as an alternative to class properties across the entire framework
Cache::touch()— extend a TTL without fetching the value
That’s it. No architectural rewrites. No migration nightmares. Just two well-considered additions that make existing patterns cleaner — and neither one breaks anything you’ve already written.
Feature 1: PHP Attributes Across the Framework
The Idea
Since PHP 8.0, the language has had native Attributes — the #[AttributeName] syntax that lets you annotate classes, methods, and properties with structured metadata. Laravel 12 and earlier largely ignored them in favour of class properties. Laravel 13 embraces them as a first-class alternative.
The key word is alternative. Every property-based approach you’re using today continues to work unchanged. This is purely additive.
Eloquent Models
This is where most developers will feel the impact daily. Before Laravel 13, a typical model had a cluster of property declarations at the top before you got to any real code:
// Before — Laravel 12 and earlier
class Invoice extends Model
{
protected $table = 'invoices';
protected $primaryKey = 'invoice_id';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'customer_id',
'number',
'status',
'subtotal',
'tax',
'total',
'due_date',
'paid_at',
];
protected $hidden = [
'internal_notes',
'cost_price',
];
protected $casts = [
'status' => InvoiceStatus::class,
'due_date' => 'date',
'paid_at' => 'datetime',
];
// ... relationships start somewhere down here
}
With Laravel 13 PHP Attributes:
// After — Laravel 13
#[Table('invoices', key: 'invoice_id', keyType: 'string', incrementing: false)]
#[Fillable(['customer_id', 'number', 'status', 'subtotal', 'tax', 'total', 'due_date', 'paid_at'])]
#[Hidden(['internal_notes', 'cost_price'])]
class Invoice extends Model
{
protected $casts = [
'status' => InvoiceStatus::class,
'due_date' => 'date',
'paid_at' => 'datetime',
];
// relationships immediately visible, no scrolling required
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}
The model’s intent is readable at a glance. Configuration sits above the class declaration where it’s visible without scrolling past properties. Relationships and methods start on line 10 instead of line 30.
Every available model attribute:
#[Appends(['full_name', 'status_label'])] // replaces $appends
#[Connection('reports')] // replaces $connection
#[Fillable(['name', 'email', 'status'])] // replaces $fillable
#[Guarded(['id', 'created_at'])] // replaces $guarded
#[Hidden(['password', 'remember_token'])] // replaces $hidden
#[Table('custom_table_name')] // replaces $table
#[Touches(['lastActivity'])] // replaces $touches
#[Unguarded] // replaces $guarded = []
#[Visible(['name', 'email'])] // replaces $visible
class User extends Model {}
Queue Jobs
Job class configuration has always been scattered. Connection, queue name, retry logic, timeout — four separate properties before you get to the handle() method. Laravel 13 consolidates them above the class:
// Before — Laravel 12
class ProcessInvoicePayment implements ShouldQueue
{
public string $connection = 'redis';
public string $queue = 'payments';
public int $tries = 3;
public int $timeout = 120;
public int $backoff = 60;
public int $maxExceptions = 2;
public bool $failOnTimeout = true;
public function handle(): void
{
// payment logic
}
}
// After — Laravel 13
#[Connection('redis')]
#[Queue('payments')]
#[Tries(3)]
#[Timeout(120)]
#[Backoff(60)]
#[MaxExceptions(2)]
#[FailOnTimeout]
class ProcessInvoicePayment implements ShouldQueue
{
public function handle(): void
{
// payment logic starts on line 1
}
}
Every available queue attribute:
#[Backoff(60)] // seconds between retries
#[Connection('redis')] // queue connection
#[FailOnTimeout] // mark as failed on timeout (flag, no value)
#[MaxExceptions(3)] // max exceptions before failure
#[Queue('high')] // queue name
#[Timeout(120)] // execution timeout in seconds
#[Tries(5)] // max retry attempts
#[UniqueFor(3600)] // unique job lock duration in seconds
These same attributes work on listeners, notifications, mailables, and broadcast events — any class that gets queued follows the same pattern.
Console Commands
Commands have always felt slightly verbose because $signature is a multiline string and $description is a separate property sitting above it. With Laravel 13:
// Before — Laravel 12
class SendInvoiceReminderCommand extends Command
{
protected $signature = 'invoices:send-reminders
{--days=7 : Days until due}
{--dry-run : Preview without sending}';
protected $description = 'Send payment reminders for invoices due soon';
public function handle(): int
{
// command logic
}
}
// After — Laravel 13
#[Signature('invoices:send-reminders {--days=7 : Days until due} {--dry-run : Preview without sending}')]
#[Description('Send payment reminders for invoices due soon')]
class SendInvoiceReminderCommand extends Command
{
public function handle(): int
{
// command logic starts immediately
}
}
Form Requests
Two attributes land for form requests — both useful for API development:
// Before — Laravel 12
class StoreInvoiceRequest extends FormRequest
{
protected $redirect = '/invoices';
public bool $stopOnFirstFailure = true;
public function rules(): array
{
return [ /* ... */ ];
}
}
// After — Laravel 13
#[RedirectTo('/invoices')]
#[StopOnFirstFailure]
class StoreInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'customer_id' => ['required', 'exists:customers,id'],
'due_date' => ['required', 'date', 'after:today'],
'line_items' => ['required', 'array', 'min:1'],
];
}
}
API Resources
// Before — Laravel 12
class InvoiceCollection extends ResourceCollection
{
public $collects = InvoiceResource::class;
public bool $preserveKeys = true;
}
// After — Laravel 13
#[Collects(InvoiceResource::class)]
#[PreserveKeys]
class InvoiceCollection extends ResourceCollection {}
Factories and Test Seeders
// Factory — explicit model binding
#[UseModel(Invoice::class)]
class InvoiceFactory extends Factory
{
public function definition(): array
{
return [ /* ... */ ];
}
}
// Test — run seeders with attributes
#[Seed] // runs DatabaseSeeder
#[Seeder(InvoiceSeeder::class)] // runs a specific seeder
class InvoiceTest extends TestCase {}
Can You Mix Attributes and Properties?
Yes. This is explicitly supported and useful during gradual migration:
// Perfectly valid — migrate one property at a time
#[Fillable(['name', 'email'])] // migrated
class User extends Model
{
protected $hidden = ['password']; // not yet migrated — still works
}
Laravel merges both sources. You won’t get conflicts or warnings. Migrate at your own pace, file by file.
Feature 2: Cache::touch()
The Problem It Solves
Extending a cache TTL without Cache::touch() requires fetching the value and putting it back:
// Before — extends TTL but fetches value unnecessarily
$data = Cache::get('report:monthly');
if ($data !== null) {
Cache::put('report:monthly', $data, now()->addHours(6));
}
This has real costs:
- Network overhead — fetches potentially large cached values over the wire
- Memory overhead — loads the value into PHP memory just to re-store it
- Race condition — between
getandput, another process could update the value; you’d overwrite it
The Solution
// After — Laravel 13
Cache::touch('report:monthly', now()->addHours(6));
One line. No fetch. No re-store. No race condition.
All three calling conventions:
// Extend by seconds
Cache::touch('user_session:42', 3600);
// Extend with a DateTime/Carbon instance
Cache::touch('analytics_cache', now()->addHours(6));
// Extend indefinitely (removes TTL)
Cache::touch('permanent_config', null);
Return value: true if the key exists and was updated, false if the key doesn’t exist (already expired or never set).
Under the hood by driver:
- Redis → single
EXPIREcommand - Memcached →
TOUCHcommand - Database → single
UPDATE(no SELECT) - File → updates the file’s expiry timestamp
- Array, APC, DynamoDB, Memoized, Null → all implemented
Real Use Cases
// Extend a user's session on activity
public function middleware(Request $request, Closure $next): Response
{
$response = $next($request);
if (auth()->check()) {
Cache::touch(
'user_activity:' . auth()->id(),
now()->addMinutes(30)
);
}
return $response;
}
// Extend a report cache when it's still valid
public function getMonthlyReport(): array
{
$key = 'report:monthly:' . now()->format('Y-m');
if (Cache::has($key)) {
// Already computed — extend TTL and return
Cache::touch($key, now()->addHours(2));
return Cache::get($key);
}
$report = $this->computeExpensiveReport();
Cache::put($key, $report, now()->addHours(6));
return $report;
}
// Batch touch multiple cache keys
$keys = ['config:features', 'config:limits', 'config:pricing'];
foreach ($keys as $key) {
Cache::touch($key, now()->addDay());
}
The Upgrade Path
Laravel 13 requires PHP 8.3 minimum. That’s the only hard requirement.
# Check your current PHP version
php --version
# Update composer.json
"require": {
"php": "^8.3",
"laravel/framework": "^13.0"
}
# Run the upgrade
composer update
# Or use Laravel Shift for automated upgrade
If you’re on PHP 8.2: upgrade PHP first. PHP 8.3 has been stable since November 2023. Most hosting providers have had it available for over a year. There’s no reason to stay on 8.2.
If you’re on Laravel 12: there are no application-breaking changes in Laravel 13. After updating PHP and running composer update, your application should run without modification.
The new Attributes are entirely opt-in. You can adopt them on new files immediately and leave existing models untouched indefinitely. There’s no deprecation warning for the property-based approach.
Should You Adopt Attributes Immediately?
For new projects starting after March 17: yes, use Attributes from day one. They’re cleaner, more readable, and signal intent at the top of the class rather than burying configuration in properties.
For existing projects: adopt them gradually. The best time to migrate a model is when you’re already editing it. Don’t create a separate PR just to move properties to attributes — let it happen organically as you work through the codebase.
For Cache::touch(): adopt it immediately wherever you’re doing TTL extension. Every instance of Cache::get() followed by Cache::put() solely to extend a TTL is a candidate for replacement.
The Bigger Picture
Laravel 13 is a deliberately conservative release. Two well-scoped features. No breaking changes. A PHP version bump that was overdue.
That’s not a weakness — it’s the release strategy that keeps Laravel’s upgrade path painless. The framework team has earned trust by never making major upgrades traumatic. Laravel 13 continues that record.
The Attributes feature is the more significant of the two. It’s the framework saying: PHP’s language features have matured enough to use directly, without wrapping them in our own patterns. That alignment between framework conventions and language idioms tends to compound over time — expect more Attribute-based APIs in Laravel 14 and beyond.
Mark March 17 in your calendar. The upgrade will take less than an afternoon.
Follow me for daily deep-dives on Laravel, PHP, Vue.js, and AI integrations. New article every day.
