---
title: "Laravel 13: A Deep Technical Dive Into What Actually Changed Under the Hood"
description: "A senior developer's breakdown of Laravel 13 — PHP attributes across 36+ locations, native vector search with pgvector, Cache::touch(), Queue::route(), JSON:API resources, and the AI SDK. Real code, real architecture decisions."
canonical_url: "https://claritytech.io/posts/laravel-13-deep-dive/"
---

> Index of every page on this site: [llms.txt](https://claritytech.io/llms.txt)

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:

```php
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:

```php
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:

```php
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

```php
#[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:

```php
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`:

```php
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:

```php
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

```php
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

```php
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

```php
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.

```php
$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

1. The query string is sent to your configured embedding model (OpenAI, Anthropic, etc.) via the AI SDK
2. The returned vector is compared against the `embedding` column using pgvector's `<=>` cosine distance operator
3. Results below the `minSimilarity` threshold are filtered out
4. 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

```php
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**.

```php
// 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:

```php
// 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`, `relationships` structure
- **Relationship inclusion** — `?include=author,comments` query parameter
- **Sparse fieldsets** — `?fields[posts]=title,body` to 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

```php
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:

```php
'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:

```php
'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:

1. Update `composer.json`: `"laravel/framework": "^13.0"`
2. Ensure PHP >= 8.3
3. Review the `serializable_classes` cache config if you cache PHP objects
4. 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.

## Sitemap

See the full [sitemap](https://claritytech.io/sitemap.md) for all pages.
