Laravel 13: A Deep Technical Dive Into What Actually Changed Under the Hood

Laravel 13 dropped on March 17, 2026 with a promise that sounds almost too good: zero breaking changes. And for the most part, Taylor Otwell delivered. But "zero breaking changes" doesn't mean "nothing happened." Quite the opposite — this release reshapes how you structure models, configure jobs, query vectors, and build AI features, all without forcing a single line of migration code.
Let's skip the marketing bullet points and dig into what actually matters.
PHP 8.3 Is Now the Floor
Laravel 13 drops PHP 8.2 support. This isn't just a version bump — it unlocks json_validate(), typed class constants, #[\Override], and the Randomizer additions that the framework now leans on internally. If you're still on 8.2, this is your forcing function.
Support window: bug fixes through Q3 2027, security patches through Q1 2028.
PHP Attributes: 36 New Attributes, One Architectural Shift
This is the headline feature, and it deserves more than a passing mention. Laravel 13 introduces 36 new attribute classes across models, jobs, commands, form requests, resources, factories, and tests. Combined with the 17+ that already existed since Laravel 11-12, you now have 50+ attributes available.
But here's what most articles miss: this isn't syntactic sugar. It's a shift toward declarative configuration that the framework can introspect at boot time, opening the door for better static analysis, IDE support, and tooling.
Eloquent Model Attributes
The #[Table] attribute is the most powerful — it replaces six separate properties in a single declaration:
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
#[Table(
name: 'external_orders',
key: 'uuid',
keyType: 'string',
incrementing: false,
timestamps: false,
)]
#[Fillable(['customer_id', 'total', 'currency', 'status'])]
#[Hidden(['internal_notes', 'margin'])]
class ExternalOrder extends Model {}
Compare that to the old way:
class ExternalOrder extends Model
{
protected $table = 'external_orders';
protected $primaryKey = 'uuid';
protected $keyType = 'string';
public $incrementing = false;
public $timestamps = false;
protected $fillable = ['customer_id', 'total', 'currency', 'status'];
protected $hidden = ['internal_notes', 'margin'];
}
That's 7 lines of scattered property declarations collapsed into 3 focused attributes at the top of the class. The model body is now free for relationships, scopes, and business logic.
The New #[Unguarded] Attribute
This one has no property equivalent. Previously, to make a model unguarded, you had to call Model::unguard() globally in a service provider, which affected all models. Now:
use Illuminate\Database\Eloquent\Attributes\Unguarded;
#[Unguarded]
class AppSetting extends Model {}
Scoped unguarding per model. This is useful for internal config tables or seed-only models where mass assignment protection is meaningless overhead.
Other Model Attributes
#[Connection('analytics')] // Dedicated DB connection
#[Touches(['post'])] // Auto-touch parent timestamps
#[Visible(['id', 'name'])] // Whitelist serialization (inverse of Hidden)
#[Appends(['full_name'])] // Append accessors to serialization
Queue Job Attributes
This is where attributes shine the most. Job configuration was always awkward — you'd define $tries as a property, $backoff as a method, and $timeout somewhere in between. Now everything sits at the class level:
use Illuminate\Queue\Attributes\{Connection, Queue, Tries, Timeout, Backoff, MaxExceptions, FailOnTimeout};
#[Connection('redis')]
#[Queue('payments')]
#[Tries(3)]
#[Timeout(60)]
#[Backoff([5, 15, 30])]
#[MaxExceptions(2)]
#[FailOnTimeout]
class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
// Your job logic — no configuration noise here
}
}
The #[Backoff([5, 15, 30])] syntax is new — pass an array for exponential backoff intervals. Previously this required a backoff() method returning an array.
The #[UniqueFor] attribute replaces implementing ShouldBeUnique plus defining $uniqueFor:
use Illuminate\Queue\Attributes\UniqueFor;
use Illuminate\Contracts\Queue\ShouldBeUnique;
#[UniqueFor(3600)]
class RebuildSearchIndex implements ShouldQueue, ShouldBeUnique
{
// Unique lock for 1 hour — no property/method needed
}
Controller Attributes: Middleware and Authorization
This removes the need for __construct() middleware registration entirely:
use Illuminate\Routing\Attributes\Controllers\{Middleware, Authorize};
#[Middleware('auth')]
#[Authorize('manage-users', only: ['edit', 'update', 'destroy'])]
class UserController extends Controller
{
#[Middleware('throttle:10,1')]
public function store(Request $request)
{
// ...
}
#[Authorize('delete', User::class)]
public function destroy(User $user)
{
// ...
}
}
The only and except parameters work just like they did with $this->middleware(), but are now statically analyzable.
Console Command Attributes
use Illuminate\Console\Attributes\{Signature, Description, Usage, Hidden};
#[Signature('orders:reconcile {--since= : Start date} {--dry-run}')]
#[Description('Reconcile order totals against payment gateway')]
#[Usage('orders:reconcile --since=2026-01-01')]
#[Usage('orders:reconcile --since=2026-01-01 --dry-run')]
class ReconcileOrders extends Command
{
public function handle(): int
{
// No $signature or $description properties needed
}
}
Multiple #[Usage] attributes stack — a small but thoughtful touch for help screen documentation.
Form Request Attributes
use Illuminate\Foundation\Http\Attributes\{ErrorBag, RedirectToRoute, StopOnFirstFailure};
#[ErrorBag('createPost')]
#[RedirectToRoute('posts.create')]
#[StopOnFirstFailure]
class StorePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => 'required|max:255',
'body' => 'required',
];
}
}
#[StopOnFirstFailure] is particularly useful for wizard-style forms where validating everything at once is wasted effort.
Testing Attributes
use Illuminate\Foundation\Testing\Attributes\{Seed, SetUp, TearDown};
#[Seed(RoleAndPermissionSeeder::class)]
class AdminAccessTest extends TestCase
{
private User $admin;
#[SetUp]
public function createAdmin(): void
{
$this->admin = User::factory()->admin()->create();
}
#[TearDown]
public function clearPermissionCache(): void
{
app()['permission.registrar']->forgetCachedPermissions();
}
public function test_admin_can_access_dashboard(): void
{
$this->actingAs($this->admin)
->get('/admin')
->assertOk();
}
}
#[SetUp] and #[TearDown] let you name your setup methods descriptively instead of overriding setUp() with a growing block of unrelated initialization.
Native Vector Search with pgvector
This is the feature that flew under the radar. Laravel 13 adds whereVectorSimilarTo() to the query builder for native semantic search using PostgreSQL's pgvector extension.
$results = DB::table('documents')
->whereVectorSimilarTo('embedding', 'Best wineries in Napa Valley')
->limit(10)
->get();
What's happening here: the string is converted to an embedding vector via the configured AI provider (through the AI SDK), then compared against stored vectors using cosine similarity. Results are automatically ordered by relevance.
How It Works Internally
- The query string is sent to your configured embedding model (OpenAI, Anthropic, etc.) via the AI SDK
- The returned vector is compared against the
embeddingcolumn using pgvector's<=>cosine distance operator - Results below the
minSimilaritythreshold are filtered out - An HNSW (Hierarchical Navigable Small World) index is automatically created on vector columns, giving you approximate nearest neighbor search that scales to millions of rows
This eliminates the need for separate vector databases like Pinecone or Weaviate for most use cases. Your embeddings live alongside your relational data in PostgreSQL.
Migration Example
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->text('content');
$table->vector('embedding', 1536); // 1536 dimensions for OpenAI ada-002
$table->timestamps();
});
Cache::touch() — Finally
A deceptively simple addition that solves a real problem. Cache::touch() extends a cached item's TTL without reading or re-storing the value.
// Extend session TTL on activity — no deserialization overhead
Cache::touch('user_session:123', 3600);
// Use Carbon for readability
Cache::touch('analytics_data', now()->addHours(6));
// Remove TTL entirely (make permanent)
Cache::touch('report_cache', null);
Returns true on success, false if the key doesn't exist.
Why This Matters
Previously, extending a TTL required Cache::get() + Cache::put(), which meant deserializing and re-serializing the value. For large cached objects (compiled views, serialized collections, aggregated reports), this was wasteful. Cache::touch() is a single atomic operation on every driver — Redis EXPIRE, Memcached touch, and equivalent operations on Database, DynamoDB, File, and APC.
Queue::route() — Centralized Job Routing
Instead of scattering queue/connection config across dozens of job classes:
// In a service provider
Queue::route(ProcessPayment::class, connection: 'redis', queue: 'payments');
Queue::route(SendNewsletter::class, connection: 'sqs', queue: 'emails');
Queue::route(GenerateReport::class, connection: 'redis', queue: 'reports');
This gives you a single place to see and manage your entire job topology. It also means you can change routing without modifying job classes — useful when migrating queue backends or rebalancing workers.
Job-level attributes (#[Connection], #[Queue]) take precedence over Queue::route(), so you can use routing as a default with per-job overrides.
JSON:API Resources
Laravel 13 ships first-party JSON:API specification support. If you've used spatie/laravel-json-api or laravel-json-api/laravel, you know the pain of wiring up compliant responses. Now it's built in.
The new resource classes handle:
- Resource object serialization —
type,id,attributes,relationshipsstructure - Relationship inclusion —
?include=author,commentsquery parameter - Sparse fieldsets —
?fields[posts]=title,bodyto reduce payload - Links — self, related, and pagination links
- Compliant headers —
Content-Type: application/vnd.api+json
This is significant for teams building APIs consumed by Ember.js, JSON:API-compliant mobile clients, or any frontend that benefits from a standardized response format.
Laravel AI SDK — Production Stable
The AI SDK that was beta in Laravel 12 is now stable. It provides a provider-agnostic interface for:
- Text generation
- Tool-calling agents
- Image creation
- Audio synthesis
- Embedding generation
- Vector store integrations
use App\Agents\SalesCoach;
// Streaming response
Route::get('/coach', function () {
return (new SalesCoach)->stream('Analyze this sales transcript...');
});
// Queued agent with promise-style callbacks
Route::post('/coach', function (Request $request) {
return (new SalesCoach)
->queue($request->input('transcript'))
->then(function (AgentResponse $response) {
// Process result
})
->catch(function (Throwable $e) {
// Handle error
});
});
The SDK handles retry logic, error normalization, and queue integration behind the scenes. Swap providers by changing config, not code.
Multi-Tenancy Teams in Starter Kits
Laravel 13 brings team-based multi-tenancy back to official starter kits — but with a critical architectural change from the old Jetstream implementation.
The old way (Jetstream): Teams were session-based. The "current team" was stored in the session, which meant you couldn't operate in two team contexts simultaneously — opening a second tab shared the same session state.
The new way (Laravel 13): Teams are URL-routed. The team context is part of the URL path, so different tabs can operate in different team contexts. This is how multi-tenancy should work in a stateless HTTP world.
Security: Cache Serialization Hardening
A subtle but important change. The default cache config now includes:
'serializable_classes' => false,
This prevents PHP object deserialization from cache stores entirely by default. If your APP_KEY is leaked, an attacker can no longer exploit deserialization gadget chains through cached values. If you intentionally cache PHP objects, you'll need to explicitly whitelist those classes:
'serializable_classes' => [
App\DataTransferObjects\ReportData::class,
App\ValueObjects\Money::class,
],
This is defense-in-depth done right — secure by default, opt-in for specific use cases.
Upgrading from Laravel 12
The upgrade is genuinely painless for most apps:
- Update
composer.json:"laravel/framework": "^13.0" - Ensure PHP >= 8.3
- Review the
serializable_classescache config if you cache PHP objects - Run
composer update
Laravel Boost (the first-party MCP server) offers guided upgrades via /upgrade-laravel-v13 in Claude Code, Cursor, or VS Code if you want automated migration assistance.
All attribute features are opt-in. Your existing property-based configuration continues to work unchanged. You can adopt attributes incrementally — even mixing properties and attributes in the same class.
Final Thoughts
Laravel 13 is a "quality of life" release that manages to be architecturally significant without being disruptive. The attributes system, vector search, and AI SDK aren't just conveniences — they signal where the framework is headed: declarative configuration, AI-native tooling, and fewer external dependencies for common patterns.
The zero-breaking-changes promise is real. Upgrade, then adopt new features at your own pace. That's how framework evolution should work.