Laravel 13 Code Updates That Actually Improve Your App — With Before/After Examples (2026)
Most developers upgraded to Laravel 13 and moved on.
Update the composer.json. Run composer update. Check that nothing broke. Ship. Done.
That is the upgrade. That is not the improvement.
Laravel 13 focuses on framework stability and quality-of-life improvements rather than breaking changes. Features like PHP Attributes and Cache::touch() may seem small, but they significantly improve how developers write and maintain Laravel applications. Appeaktech
The real value of Laravel 13 is not in the upgrade — it is in the specific code patterns it unlocks. This guide covers every practical change you can make to an existing application right now, with before/after code for each one.
1. PHP Attributes — Clean Up Your Models
Laravel 13 introduces native PHP attribute syntax as an optional alternative to class property declarations across more than 15 locations in the framework — including models, controllers, jobs, commands, listeners, and mailables. Medium
This is the change with the widest surface area. Every model in your application is a candidate.
Before:
php
class Post extends Model
{
protected $table = 'posts';
protected $primaryKey = 'post_id';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'title', 'body', 'slug',
'status', 'user_id', 'category_id',
];
protected $hidden = [
'internal_notes',
'admin_flag',
'moderation_score',
];
protected $casts = [
'published_at' => 'datetime',
'metadata' => 'array',
'is_featured' => 'boolean',
];
}
After:
php
#[Table('posts', key: 'post_id', keyType: 'string', incrementing: false)]
#[Fillable(['title', 'body', 'slug', 'status', 'user_id', 'category_id'])]
#[Hidden(['internal_notes', 'admin_flag', 'moderation_score'])]
#[Cast(['published_at' => 'datetime', 'metadata' => 'array', 'is_featured' => 'boolean'])]
class Post extends Model
{
// Only actual logic lives here now
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')
->whereNotNull('published_at');
}
}
The model now reads top-to-bottom as a contract: here is the table, here is what can be filled, here is what is hidden, here are the types — then the actual behavior.
Apply to Commands too:
php
// Before
class SendDailyDigest extends Command
{
protected $signature = 'digest:send {--force : Force send even if already sent today}';
protected $description = 'Send daily digest emails to all active subscribers';
}
// After
#[Signature('digest:send {--force : Force send even if already sent today}')]
#[Description('Send daily digest emails to all active subscribers')]
class SendDailyDigest extends Command
{
public function handle(): int
{
// Just the logic
}
}
And Controllers:
php
// Before — in constructor
class PostController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('verified')->only(['store', 'update', 'destroy']);
$this->middleware('throttle:60,1')->only(['index', 'show']);
}
}
// After — on the class
#[Middleware('auth')]
#[Middleware('verified', only: ['store', 'update', 'destroy'])]
#[Middleware('throttle:60,1', only: ['index', 'show'])]
class PostController extends Controller
{
// No constructor needed
}
2. Typed Configuration — Catch Env Mistakes at Boot
One of the quietest but most impactful changes in Laravel 13.
The problem it solves:
bash
# .env — someone accidentally sets a wrong type APP_DEBUG=yes # Should be true/false CACHE_TTL=5 minutes # Should be an integer APP_NAME= # Should be a non-empty string
With untyped config calls, these mistakes silently produce the wrong behavior at runtime — sometimes days or weeks after deployment.
Before:
php
// Returns mixed — string? bool? null? who knows at runtime
$debug = config('app.debug');
$name = config('app.name');
$ttl = config('cache.ttl');
$maxTries = config('queue.max_tries');
// You're forced to cast manually everywhere
$isDebug = (bool) config('app.debug');
$ttlInt = (int) config('cache.ttl');
After:
php
// Throws ConfigTypeMismatchException at boot if type is wrong
$debug = config()->boolean('app.debug'); // guaranteed bool
$name = config()->string('app.name'); // guaranteed string
$ttl = config()->integer('cache.ttl'); // guaranteed int
$ratio = config()->float('app.sample_rate'); // guaranteed float
// Catches misconfigured .env at application startup
// Not three weeks later when a user hits the wrong code path
Practical refactor — your config service:
php
// Before — type uncertainty everywhere
class FeatureService
{
public function isEnabled(string $feature): bool
{
return (bool) config("features.{$feature}", false);
}
public function getLimit(string $feature): int
{
return (int) config("features.{$feature}_limit", 100);
}
}
// After — typed, clean, explicit
class FeatureService
{
public function isEnabled(string $feature): bool
{
return config()->boolean("features.{$feature}", false);
}
public function getLimit(string $feature): int
{
return config()->integer("features.{$feature}_limit", 100);
}
}
3. Cache::touch() — Stop Fetching to Extend
A pattern that exists in almost every application with sessions, tokens, or rate limiting.
Before:
php
// Extending a cache item's TTL — the old way
// Had to fetch the full value just to write it back
public function extendSession(string $sessionKey): void
{
$sessionData = Cache::get($sessionKey); // Round trip 1 — fetch
if ($sessionData !== null) {
Cache::put( // Round trip 2 — write
$sessionKey,
$sessionData,
now()->addHours(2)
);
}
}
// Two Redis calls for what is conceptually a TTL extension
After:
php
// One Redis EXPIRE command — no value fetch
public function extendSession(string $sessionKey): void
{
Cache::touch($sessionKey, now()->addHours(2));
}
// Real-world: keep API tokens alive on activity
public function refreshTokenExpiry(Request $request): void
{
$tokenKey = "api_token_{$request->user()->id}";
Cache::touch($tokenKey, now()->addMinutes(30));
}
// Rate limiting — reset window on activity
public function resetRateWindow(string $identifier): void
{
Cache::touch("rate_limit_{$identifier}", now()->addMinute());
}
4. Queue::route() — Centralized Job Routing
Before Laravel 13, queue configuration was scattered across every job class.
Before:
php
// Connection and queue defined on EVERY job class
class ProcessPayment implements ShouldQueue
{
public $connection = 'redis';
public $queue = 'payments';
public $tries = 3;
public $timeout = 60;
}
class SendWelcomeEmail implements ShouldQueue
{
public $connection = 'sqs';
public $queue = 'emails';
}
class GenerateReport implements ShouldQueue
{
public $connection = 'database';
public $queue = 'reports';
}
class SyncInventory implements ShouldQueue
{
public $connection = 'redis';
public $queue = 'sync';
}
// Want to move payments from Redis to SQS?
// Edit every payment-related job class.
After:
php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;
public function boot(): void
{
Queue::route([
ProcessPayment::class => 'payments@redis',
SendWelcomeEmail::class => 'emails@sqs',
GenerateReport::class => 'reports@database',
SyncInventory::class => 'sync@redis',
ProcessRefund::class => 'payments@redis',
SendNotification::class => 'emails@sqs',
]);
}
// Job classes have no connection or queue properties
class ProcessPayment implements ShouldQueue
{
public function __construct(public Order $order) {}
public function handle(): void
{
// Just the logic
}
}
// Change queue topology in one place, zero job class changes
5. Debounceable Jobs — Fix Bursty Workloads
This pattern solves a problem that almost every event-driven application has.
The problem:
php
// User edits a document — fires an event on every keystroke
// Each event dispatches a job
class DocumentController extends Controller
{
public function update(UpdateDocumentRequest $request, Document $document): JsonResponse
{
$document->update($request->validated());
// User types 40 characters = 40 rebuild jobs in the queue
dispatch(new RebuildSearchIndex($document->id));
dispatch(new GenerateDocumentPreview($document->id));
dispatch(new SyncToExternalService($document->id));
return new DocumentResource($document);
}
}
Before — manual debounce:
php
// Had to build debouncing yourself
class RebuildSearchIndex implements ShouldQueue
{
public function handle(): void
{
// Check if a newer job exists in the queue
// If yes, skip this one
// Complex, error-prone, hard to test
}
}
After — Laravel 13:
php
// On the job class
#[DebounceFor(30)] // seconds
class RebuildSearchIndex implements ShouldQueue
{
public function __construct(public int $documentId) {}
public function debounceId(): string
{
return "search-index-{$this->documentId}";
}
public function handle(): void
{
Document::find($this->documentId)?->rebuildIndex();
}
}
// User types 40 characters in 30 seconds — one job runs, not 40
At the call site without touching the job class:
php
// Debounce without modifying the job class
dispatch(new SyncToExternalService($document->id))
->debounceFor(seconds: 30);
// Different debounce window at dispatch time
dispatch(new GenerateDocumentPreview($document->id))
->debounceFor(seconds: 10);
6. PHP 8.3 — json_validate()
Small function, real impact. Every application that receives JSON from external sources should be using this.
Before:
php
// Had to decode just to validate — wasted memory and CPU
public function isValidJson(string $value): bool
{
json_decode($value);
return json_last_error() === JSON_ERROR_NONE;
}
// Or the more explicit version
public function validateWebhookPayload(string $payload): bool
{
$decoded = json_decode($payload, true);
return $decoded !== null && json_last_error() === JSON_ERROR_NONE;
}
After:
php
// PHP 8.3 — validate without decoding
public function isValidJson(string $value): bool
{
return json_validate($value);
}
// Webhook validation — real use case
public function handleWebhook(Request $request): JsonResponse
{
$payload = $request->getContent();
if (!json_validate($payload)) {
return response()->json(['error' => 'Invalid JSON payload'], 400);
}
$data = json_decode($payload, true);
$this->webhookService->process($data);
return response()->json(['received' => true]);
}
7. Typed Class Constants — PHP 8.3
Status strings, event names, permission slugs — constants are everywhere in a Laravel application. PHP 8.3 typed constants make them reliable.
Before:
php
class OrderStatus
{
const PENDING = 'pending';
const PROCESSING = 'processing';
const COMPLETED = 'completed';
const CANCELLED = 'cancelled';
// Anyone can do: OrderStatus::PENDING = 123 — no error
}
class Post extends Model
{
const STATUS_DRAFT = 'draft';
const STATUS_PUBLISHED = 'published';
const CACHE_TTL = 3600;
// CACHE_TTL could be accidentally reassigned to a string — no error
}
After:
php
class OrderStatus
{
const string PENDING = 'pending';
const string PROCESSING = 'processing';
const string COMPLETED = 'completed';
const string CANCELLED = 'cancelled';
// Type enforced at declaration and access
}
class Post extends Model
{
const string STATUS_DRAFT = 'draft';
const string STATUS_PUBLISHED = 'published';
const int CACHE_TTL = 3600;
// Wrong type assignment throws at runtime
}
// Use in queries — now type-safe
$pending = Order::where('status', OrderStatus::PENDING)->get();
8. PreventRequestForgery — Two-Layer CSRF
One line change. Meaningful security improvement.
Before:
php
// bootstrap/app.php — Laravel 12 default
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\VerifyCsrfToken::class,
]);
})
After:
php
// bootstrap/app.php — Laravel 13
->withMiddleware(function (Middleware $middleware) {
$middleware->web(replace: [
\App\Http\Middleware\VerifyCsrfToken::class
=> \Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,
]);
})
What changed: PreventRequestForgery adds Origin header verification on top of token-based CSRF. Traditional CSRF tokens verify the form came from your site. Origin verification checks the request source at the HTTP level independently.
No changes required to your forms, controllers, or frontend code.
9. Enum Casts — Replace String Constants
Laravel 13 works seamlessly with PHP 8.1+ enums. If you are still using string constants for status fields, this is the upgrade that pays back continuously.
Before:
php
// String constant approach
class Order extends Model
{
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_COMPLETED = 'completed';
protected $casts = [
'status' => 'string',
];
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function complete(): void
{
$this->update(['status' => self::STATUS_COMPLETED]);
}
}
// Usage — easy to pass wrong strings
$order->update(['status' => 'complted']); // Typo — no error
After:
php
// Enum definition
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Completed = 'completed';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Pending => 'Awaiting Payment',
self::Processing => 'Being Prepared',
self::Completed => 'Delivered',
self::Cancelled => 'Cancelled',
};
}
public function canTransitionTo(self $new): bool
{
return match($this) {
self::Pending => in_array($new, [self::Processing, self::Cancelled]),
self::Processing => in_array($new, [self::Completed, self::Cancelled]),
default => false,
};
}
}
// Model with enum cast
#[Cast(['status' => OrderStatus::class])] // Laravel 13 attribute
class Order extends Model
{
public function isPending(): bool
{
return $this->status === OrderStatus::Pending;
}
public function complete(): void
{
$this->update(['status' => OrderStatus::Completed]);
}
}
// Usage — invalid values are impossible
$order->update(['status' => OrderStatus::Completed]); // Type-safe
$order->update(['status' => 'complted']); // IDE error immediately
10. First-Party AI SDK — Add Intelligence Without Plumbing
Laravel 13 ships with a cohesive set of additions that address developer experience, AI capability, queue management, and data querying. The graduation of the Laravel AI SDK from experimental to production-stable positions Laravel as the first major PHP framework with a first-party, provider-agnostic AI development layer built directly into the ecosystem. KrishaWeb
Before — stitching together third-party SDKs:
php
// Had to use OpenAI SDK directly
use OpenAI\Laravel\Facades\OpenAI;
class SupportService
{
public function generateResponse(string $ticket): string
{
$response = OpenAI::chat()->create([
'model' => 'gpt-4',
'messages' => [
['role' => 'system', 'content' => 'You are a support agent.'],
['role' => 'user', 'content' => $ticket],
],
]);
return $response->choices[0]->message->content;
}
}
// Switching from OpenAI to Anthropic = rewrite everything
After — provider-agnostic Laravel AI SDK:
php
use Illuminate\Support\Facades\AI;
class SupportService
{
public function generateResponse(string $ticket): string
{
return (string) AI::text(
system: 'You are a helpful support agent. Be concise and clear.',
prompt: $ticket
);
}
public function summarizeTickets(array $tickets): string
{
return (string) AI::text(
prompt: 'Summarize these support tickets and identify common themes: '
. implode('\n\n', $tickets)
);
}
public function generateEmbedding(string $content): array
{
return AI::embed($content)->toArray();
}
}
// Switch provider in config/ai.php — zero code changes
// 'default' => env('AI_PROVIDER', 'openai') → change to 'anthropic'
Practical Upgrade Path — Apply These in Order
Not every change is equally impactful. Apply them in this sequence:
Week 1 — Zero risk, immediate improvement:
- Switch all
config()calls to typed versions (config()->boolean(), etc.) - Replace
VerifyCsrfTokenwithPreventRequestForgeryin bootstrap/app.php - Replace
json_decode()validation checks withjson_validate() - Add typed constants to all status/type constant classes
Week 2 — Clean up models:
- Convert
$fillable,$hidden,$caststo PHP Attributes on all models - Convert
$signatureand$descriptionto Attributes on Artisan commands - Convert constructor middleware to class-level Attributes on controllers
Week 3 — Queue and cache improvements:
- Replace all TTL extension patterns with
Cache::touch() - Move job routing from job classes to
Queue::route()in AppServiceProvider - Add
#[DebounceFor]to jobs that fire repeatedly on the same entity
Week 4 — Architecture upgrades:
- Convert string constants to enums with cast on relevant models
- Integrate Laravel AI SDK for any AI features in the application
- Review and apply
Queue::route()to any remaining scattered job configs
What This Looks Like on a Real Controller
Before and after — a single controller with multiple improvements applied:
Before:
php
class DocumentController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('verified')->only(['store', 'update']);
}
public function update(Request $request, Document $document): JsonResponse
{
$maxSize = (int) config('documents.max_size');
$debug = (bool) config('app.debug');
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
]);
$this->authorize('update', $document);
$document->update($request->only(['title', 'content']));
dispatch(new RebuildSearchIndex($document->id));
dispatch(new GeneratePreview($document->id));
// Extend cache
$cacheKey = "doc_cache_{$document->id}";
$cached = Cache::get($cacheKey);
if ($cached) {
Cache::put($cacheKey, $cached, now()->addHours(1));
}
return response()->json(new DocumentResource($document));
}
}
After — Laravel 13:
php
#[Middleware('auth')]
#[Middleware('verified', only: ['store', 'update'])]
class DocumentController extends Controller
{
public function update(UpdateDocumentRequest $request, Document $document): JsonResponse
{
$maxSize = config()->integer('documents.max_size');
$this->authorize('update', $document);
$document->update($request->validated());
dispatch(new RebuildSearchIndex($document->id)); // debounced via #[DebounceFor(30)]
dispatch(new GeneratePreview($document->id)); // debounced via #[DebounceFor(10)]
Cache::touch("doc_cache_{$document->id}", now()->addHours(1));
return new DocumentResource($document);
}
}
Same functionality. Noticeably cleaner. Every line is doing exactly one thing.
Wrapping Up
Laravel 13 does not introduce risky changes. Instead it focuses on targeted improvements that modernize the framework and align it with the latest PHP ecosystem. These features significantly improve how developers write and maintain Laravel applications. SeoProfy
None of the changes in this guide require rewriting your application. All of them are targeted replacements — same behavior, cleaner code, fewer bugs, better developer experience.
The upgrade gave you access to all of this. The refactors above are how you actually use it.
Tushar Modi — Full Stack Developer, Jaipur tusharmodi.in