<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Deploynix</title>
    <description>The latest articles on DEV Community by Deploynix (@deploynix).</description>
    <link>https://dev.to/deploynix</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3800435%2Fa1f4de22-651f-46e8-adc8-a9da58944683.png</url>
      <title>DEV Community: Deploynix</title>
      <link>https://dev.to/deploynix</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deploynix"/>
    <language>en</language>
    <item>
      <title>Laravel 13 Native Attributes: Slimmer Models and a Cleaner Codebase</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:03:09 +0000</pubDate>
      <link>https://dev.to/deploynix/laravel-13-native-attributes-slimmer-models-and-a-cleaner-codebase-4585</link>
      <guid>https://dev.to/deploynix/laravel-13-native-attributes-slimmer-models-and-a-cleaner-codebase-4585</guid>
      <description>&lt;p&gt;Open the biggest model in your app right now. Ours was &lt;code&gt;Server.php&lt;/code&gt;, and before we refactored it this spring, the first 60 lines were pure configuration: a &lt;code&gt;$table&lt;/code&gt; override from an old naming decision, a 14-entry &lt;code&gt;$fillable&lt;/code&gt; array, &lt;code&gt;$hidden&lt;/code&gt; for two token columns, a &lt;code&gt;casts()&lt;/code&gt; method with nine entries, and a &lt;code&gt;$touches&lt;/code&gt; array nobody had questioned since 2024. Sixty lines before the first line of actual behavior. Every Laravel developer has a model like this, and most of us stopped seeing the clutter years ago.&lt;/p&gt;

&lt;p&gt;Laravel 13, released on March 17, 2026, finally gives that clutter a proper home. The framework now supports native PHP attributes in more than 15 locations, replacing property declarations like &lt;code&gt;$table&lt;/code&gt;, &lt;code&gt;$hidden&lt;/code&gt;, and &lt;code&gt;$fillable&lt;/code&gt; on models and extending the same treatment to event listeners, notifications, mailables, and broadcast events (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;, 2026). Crucially, the release ships with no app-code breaking changes: your property-based models keep working exactly as before (&lt;a href="https://www.krishaweb.com/blog/laravel-13-key-features/" rel="noopener noreferrer"&gt;KrishaWeb&lt;/a&gt;, 2026).&lt;/p&gt;

&lt;p&gt;We've now migrated most of the Deploynix codebase to the attribute style, and we deploy Laravel apps for a living, so we've also watched dozens of customer apps make the same transition. This post covers what attributes actually are, why the old property-based configuration aged badly, a full tour of the new surface with before/after code, and a migration plan that won't wreck your sprint. If you're still planning the version jump itself, start with our guide to &lt;a href="https://deploynix.io/blog/upgrading-to-laravel-13-in-production-with-zero-downtime" rel="noopener noreferrer"&gt;upgrading to Laravel 13 in production with zero downtime&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- Laravel 13 (March 17, 2026) adds native PHP attributes to 15+ framework locations, with PHP 8.3 as the minimum version (Laravel News, 2026) - Property-based config still works; there are no app-code breaking changes - Attributes are reflection-read and cached, so runtime cost is effectively zero - Migrate incrementally, one namespace at a time, with tests and small deploys&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Are PHP Attributes, and Why Did Laravel Adopt Them Now?
&lt;/h2&gt;

&lt;p&gt;PHP attributes are structured metadata you attach to classes, methods, and properties, introduced in PHP 8.0 back in 2020. The engine parses them at compile time and exposes them through reflection. Laravel 13 leans on them across 15+ framework locations, a move made practical by the framework's new PHP 8.3 minimum requirement (&lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;, 2026).&lt;/p&gt;

&lt;p&gt;If you've written a &lt;code&gt;#[Test]&lt;/code&gt; annotation in PHPUnit or a &lt;code&gt;#[Route]&lt;/code&gt; in Symfony, you already know the shape. An attribute is a small, dedicated class. When you write &lt;code&gt;#[Table('legacy_servers')]&lt;/code&gt; above a model, you're instantiating real, typed, autoloadable code that your IDE can resolve, your static analyzer can inspect, and the framework can read through reflection.&lt;/p&gt;

&lt;p&gt;That last part matters more than it sounds. Laravel has always been a convention-driven framework, and conventions used to live in loosely typed protected properties and magic strings. Attributes let the same conventions live in declared, discoverable classes instead. Nothing about Eloquent's behavior changed in Laravel 13. What changed is where the configuration lives and how much of it your tooling can actually see.&lt;/p&gt;

&lt;p&gt;Laravel didn't invent this pattern overnight, either. The framework had been testing the water for two major versions: &lt;code&gt;#[ObservedBy]&lt;/code&gt; and &lt;code&gt;#[ScopedBy]&lt;/code&gt; on models, attribute-based route model binding tweaks, and container contextual attributes all landed earlier. Laravel 13 is the point where the pattern went from a garnish to the recommended default. And with PHP 8.5 now stable, the language side of this story is thoroughly settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Did Property-Based Configuration Age So Badly?
&lt;/h2&gt;

&lt;p&gt;The short answer: property config scattered one model's identity across five untyped declarations, and tooling could never fully understand any of them. Laravel 13's attribute push exists precisely because the framework team saw the same friction at scale across 15+ configuration points (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;, 2026). Three specific problems kept biting us.&lt;/p&gt;

&lt;p&gt;First, magic strings everywhere. &lt;code&gt;protected $table = 'legacy_servers'&lt;/code&gt; is a string the framework interprets at runtime. So is every entry in &lt;code&gt;$fillable&lt;/code&gt;, &lt;code&gt;$hidden&lt;/code&gt;, &lt;code&gt;$appends&lt;/code&gt;, and &lt;code&gt;casts()&lt;/code&gt;. Rename a column, and your IDE's refactoring tools skip every one of those strings. We once shipped a bug where a renamed &lt;code&gt;ip_address&lt;/code&gt; column stayed in &lt;code&gt;$fillable&lt;/code&gt; as the old name for three weeks. Nothing errored. Mass assignment just silently dropped the field.&lt;/p&gt;

&lt;p&gt;Second, IDE blindness. A protected array property has no schema. Your editor can't tell you that &lt;code&gt;'datetime'&lt;/code&gt; is a valid cast and &lt;code&gt;'date_time'&lt;/code&gt; isn't, because both are just strings in an array. Attribute classes have constructors with typed parameters, so autocomplete, go-to-definition, and inline docs all work the way they do for normal code.&lt;/p&gt;

&lt;p&gt;Third, scattered conventions. In a ten-person team, one developer alphabetizes &lt;code&gt;$fillable&lt;/code&gt;, another groups it by feature, a third switched that model to &lt;code&gt;$guarded = []&lt;/code&gt; in 2023, and a fourth added a &lt;code&gt;casts()&lt;/code&gt; method below 200 lines of scopes. None of these choices is wrong. Collectively, they mean no two models read the same way. Attributes don't magically enforce consistency, but they give configuration one canonical position, at the top of the class, in a syntax that linters can actually check.&lt;/p&gt;

&lt;p&gt;Was the old style unworkable? No. Plenty of successful apps will keep it for years. But if you've ever scrolled past 60 lines of arrays to find a relationship method, you know the ergonomic ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Tour of the Laravel 13 Attribute Surface
&lt;/h2&gt;

&lt;p&gt;The rest of this section walks through the four areas where attributes change day-to-day code the most: models, event listeners, notifications and mailables, and broadcast events. All of the "before" examples remain fully valid in Laravel 13, since the release intentionally avoided app-code breaking changes (&lt;a href="https://www.krishaweb.com/blog/laravel-13-key-features/" rel="noopener noreferrer"&gt;KrishaWeb&lt;/a&gt;, 2026).&lt;/p&gt;

&lt;h3&gt;
  
  
  Models: Table, Fillable, Hidden, and Casts
&lt;/h3&gt;

&lt;p&gt;Here's a trimmed version of our real &lt;code&gt;Deployment&lt;/code&gt; model, before and after. First, the Laravel 12 style:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Deployment&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasFactory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'site_deployments'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$fillable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'site_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'server_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'commit_sha'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'branch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'triggered_by'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'deploy_token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;casts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;DeploymentStatus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'is_rollback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'finished_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'datetime'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Laravel 13 attribute equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Attributes\Casts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Attributes\Fillable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Attributes\Hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Attributes\Table&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Table('site_deployments')]&lt;/span&gt;
&lt;span class="na"&gt;#[Fillable(['site_id', 'server_id', 'commit_sha', 'branch', 'status', 'triggered_by'])]&lt;/span&gt;
&lt;span class="na"&gt;#[Hidden(['deploy_token'])]&lt;/span&gt;
&lt;span class="na"&gt;#[Casts(['status' =&amp;gt; DeploymentStatus::class, 'is_rollback' =&amp;gt; 'boolean', 'finished_at' =&amp;gt; 'datetime'])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Deployment&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasFactory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The class body now contains only behavior: relationships, scopes, business methods. Configuration sits above the class name where you read it once and move on. Here's how the common model properties map:&lt;/p&gt;

&lt;p&gt;Laravel 12 property&lt;/p&gt;

&lt;p&gt;Laravel 13 attribute&lt;/p&gt;

&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;&lt;code&gt;protected $table&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[Table('...')]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Class-level, single value&lt;/p&gt;

&lt;p&gt;&lt;code&gt;protected $fillable&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[Fillable([...])]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Same array semantics&lt;/p&gt;

&lt;p&gt;&lt;code&gt;protected $guarded&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[Guarded([...])]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Mutually exclusive with &lt;code&gt;Fillable&lt;/code&gt;, as before&lt;/p&gt;

&lt;p&gt;&lt;code&gt;protected $hidden&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[Hidden([...])]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Serialization only, unchanged behavior&lt;/p&gt;

&lt;p&gt;&lt;code&gt;casts()&lt;/code&gt; method&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[Casts([...])]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Complex closures stay in the method&lt;/p&gt;

&lt;p&gt;&lt;code&gt;protected $touches&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[Touches([...])]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Same relation-name strings&lt;/p&gt;

&lt;p&gt;&lt;code&gt;protected $with&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[With([...])]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Eager-load defaults&lt;/p&gt;

&lt;p&gt;One honest caveat: casts that need runtime logic, like a cast that varies by tenant, still belong in the &lt;code&gt;casts()&lt;/code&gt; method. Attributes are static metadata. In our experience that covers maybe 5 percent of casts, and mixing the two on one model works fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event Listeners: Retiring the EventServiceProvider Wiring
&lt;/h3&gt;

&lt;p&gt;Event discovery has been drifting away from manual registration since Laravel 11, the same release cycle that started deprecating &lt;code&gt;app/Http/Kernel.php&lt;/code&gt;, which Laravel 13 has now removed entirely (&lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;, 2026). Attributes finish the job for listeners. Before, the mapping lived in a provider, far from the listener itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EventServiceProvider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceProvider&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$listen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;DeploymentFinished&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;NotifyTeamOfDeployment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;RecordDeploymentMetrics&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Laravel 13, the listener declares its own subscription, including queue configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Events\Attributes\ListensTo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Attributes\OnQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ListensTo(DeploymentFinished::class)]&lt;/span&gt;
&lt;span class="na"&gt;#[OnQueue('notifications')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotifyTeamOfDeployment&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;DeploymentFinished&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the change we'd rank highest for maintainability. When wiring lived in the provider, deleting a listener class without cleaning the provider produced a runtime error in production, not a static one. With the attribute, the listener is self-describing: open the file, and you know what it listens to and which queue it runs on. Nothing to keep in sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  Notifications and Mailables
&lt;/h3&gt;

&lt;p&gt;Notifications got the same treatment for channel routing. The old &lt;code&gt;via()&lt;/code&gt; method returning an array of strings is exactly the kind of stringly-typed convention attributes were built to replace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: Laravel 12&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeploymentFailedNotification&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Notification&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;via&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="nv"&gt;$notifiable&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'mail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'slack'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'database'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After: Laravel 13&lt;/span&gt;
&lt;span class="na"&gt;#[Channels(['mail', 'slack', 'database'])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeploymentFailedNotification&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Notification&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// via() no longer needed for static channel lists&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dynamic routing, where channels depend on the notifiable's preferences, keeps the &lt;code&gt;via()&lt;/code&gt; method. The attribute covers the static case, which for most apps is the overwhelming majority. Mailables similarly move envelope metadata like subject lines and reply-to addresses into &lt;code&gt;#[Envelope]&lt;/code&gt; attribute parameters, leaving the class body to focus on content building. If your app leans hard on notifications, this refactor pairs well with the queue and worker tuning items in our list of &lt;a href="https://deploynix.io/blog/laravel-performance-optimization-20-quick-wins-for-production-apps" rel="noopener noreferrer"&gt;20 quick performance wins for production Laravel apps&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Broadcast Events
&lt;/h3&gt;

&lt;p&gt;Broadcast events previously mixed metadata into methods: &lt;code&gt;broadcastOn()&lt;/code&gt;, &lt;code&gt;broadcastAs()&lt;/code&gt;, and a class body that was mostly ceremony. Laravel 13 lets simple cases collapse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: Laravel 12&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServerMetricsUpdated&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldBroadcastNow&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;broadcastOn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PrivateChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'servers.'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;broadcastAs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'metrics.updated'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After: Laravel 13&lt;/span&gt;
&lt;span class="na"&gt;#[BroadcastOn('private:servers.{server}')]&lt;/span&gt;
&lt;span class="na"&gt;#[BroadcastAs('metrics.updated')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServerMetricsUpdated&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldBroadcastNow&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Server&lt;/span&gt; &lt;span class="nv"&gt;$server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The channel template syntax resolves &lt;code&gt;{server}&lt;/code&gt; against the event's public properties. For our Reverb-powered dashboards, this cut most broadcast event classes roughly in half. Events with genuinely dynamic channel logic keep their methods, same story as casts and notification channels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Go Attribute-First, Stay Property-Based, or Run Mixed?
&lt;/h2&gt;

&lt;p&gt;Yes, mixed is fine, and it's how nearly everyone will actually live for the next year or two. Laravel 13 guarantees both styles work side by side with no breaking changes, and even a single class can combine an attribute for &lt;code&gt;$table&lt;/code&gt; with a &lt;code&gt;casts()&lt;/code&gt; method (&lt;a href="https://www.krishaweb.com/blog/laravel-13-key-features/" rel="noopener noreferrer"&gt;KrishaWeb&lt;/a&gt;, 2026). Still, it helps to pick a direction deliberately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attribute-First
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Configuration is typed, discoverable, and consistently positioned. IDE autocomplete and go-to-definition work on config. Listener wiring is self-documenting. New classes are visibly slimmer, often by 30 to 60 lines on large models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Actively developed apps already on Laravel 13, teams that generate new models and listeners weekly, codebases with heavy static analysis investment (PHPStan or Psalm in CI).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Requires PHP 8.3 or newer, and every developer needs a short adjustment period. Dynamic config still needs methods, so you'll never be 100 percent attributes. Older tutorials and Stack Overflow answers will show the property style for years.&lt;/p&gt;

&lt;h3&gt;
  
  
  Property-Based (Status Quo)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Zero migration effort. Every Laravel developer on Earth reads it fluently. Fifteen years of documentation, packages, and examples assume it. Fully supported in Laravel 13 with no deprecation warnings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Small stable apps in maintenance mode, teams mid-upgrade with bigger problems to solve first, packages that must support Laravel 12, which remains an actively supported release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; You keep every ergonomic problem described above: magic strings, IDE blindness, scattered conventions. New hires arriving from attribute-first codebases will find it dated. The framework's documentation now leads with attributes, so the property style slowly becomes the "legacy" path in docs and examples.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mixed, With a Convention
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Lets you migrate incrementally with no big-bang risk. New code gets the modern style immediately, and old code migrates when touched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Most real production teams, honestly. Anyone with more than 50 models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Without a written convention it decays into inconsistency, the exact disease you're treating. Write the rule down: for us it's "new classes use attributes; any model you materially edit gets converted in the same PR; no drive-by conversions in unrelated PRs." That last clause keeps diffs reviewable.&lt;/p&gt;

&lt;p&gt;Rector deserves a mention here. The Rector Laravel rule set gained attribute-migration rules shortly after release, and they handle the mechanical 90 percent: &lt;code&gt;$table&lt;/code&gt;, &lt;code&gt;$fillable&lt;/code&gt;, &lt;code&gt;$hidden&lt;/code&gt;, static casts, and listener wiring. We ran it namespace by namespace rather than repo-wide, reviewed each diff by hand, and caught two models where &lt;code&gt;$guarded&lt;/code&gt; and &lt;code&gt;$fillable&lt;/code&gt; had somehow coexisted for years. The tool converts syntax; it can't convert judgment, so treat its output as a draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Static Analysis and IDE Support?
&lt;/h2&gt;

&lt;p&gt;This is where attributes quietly pay for the whole migration. Because an attribute is a real class with a real constructor, &lt;code&gt;#[Casts(['status' =&amp;gt; DeploymentStatus::class])]&lt;/code&gt; gives your tooling a &lt;code&gt;::class&lt;/code&gt; constant it can verify, rename, and track for usages. The string &lt;code&gt;'status'&lt;/code&gt; is still a string, but PHPStan's Laravel extension can now cross-check attribute arrays against your migrations and model doc-blocks far more reliably than it ever could with protected properties inherited through Eloquent's magic.&lt;/p&gt;

&lt;p&gt;Concretely, here's what improved for us after converting: renaming a cast enum class updates the attribute automatically via IDE refactoring. Find-usages on an event class now surfaces every &lt;code&gt;#[ListensTo]&lt;/code&gt; declaration, which made an event-flow audit take an afternoon instead of a week. And PHPStan flags a typo'd cast type at level 6, where before it sailed through at max level because arrays of strings carry no schema.&lt;/p&gt;

&lt;p&gt;PhpStorm's 2026.1 release and the Laravel Idea plugin both shipped completion and inspections for the new attributes within weeks of the March release. If your team's editor situation is more varied, the fallback story is still fine, since attributes are plain PHP syntax that highlights correctly everywhere. The one rough edge we hit: some older code-generation tools still scaffold property-style models, so check your stubs. &lt;code&gt;php artisan stub:publish&lt;/code&gt; and a ten-minute edit brings your &lt;code&gt;make:model&lt;/code&gt; output into the attribute era.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do Attributes Cost Anything at Runtime?
&lt;/h2&gt;

&lt;p&gt;No, and this deserves a direct answer because "reflection" makes performance-minded engineers flinch. Laravel reads attribute metadata through reflection once, then caches the result in the same metadata caches that already store property-based configuration. The framework team confirmed there's no measurable runtime difference between the two styles (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;, 2026).&lt;/p&gt;

&lt;p&gt;We verified this ourselves anyway, because trust is good and benchmarks are better. On our own API, p50 response times before and after converting roughly 80 models were identical within noise, under half a millisecond of variance across a week of production traffic on both sides of the deploy. With opcache and Laravel's bootstrap caching in play, attribute reflection happens at cache-build time, not per request.&lt;/p&gt;

&lt;p&gt;There's one operational implication worth knowing: because attribute metadata participates in framework caching, your deploy pipeline should run the standard cache rebuild steps, &lt;code&gt;php artisan optimize&lt;/code&gt; or the equivalent, on every release. If your pipeline dates from a couple of major versions ago, our rundown of &lt;a href="https://deploynix.io/blog/laravel-12-deployment-changes-what-you-need-to-know" rel="noopener noreferrer"&gt;Laravel 12's deployment changes&lt;/a&gt; covers the cache and Kernel-removal groundwork that Laravel 13 builds on, since &lt;code&gt;app/Http/Kernel.php&lt;/code&gt; had been deprecated since Laravel 11 and is now gone entirely (&lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;, 2026).&lt;/p&gt;

&lt;h2&gt;
  
  
  When Is the Refactor Not Worth It?
&lt;/h2&gt;

&lt;p&gt;Skip it when the codebase is small, stable, and rarely touched. A 15-model internal tool that gets two commits a quarter gains nothing from a mechanical rewrite, and every line you change is a line you can break. Laravel 13 supports the property style indefinitely with no deprecation timeline announced, so "never" is a legitimate migration date (&lt;a href="https://www.krishaweb.com/blog/laravel-13-key-features/" rel="noopener noreferrer"&gt;KrishaWeb&lt;/a&gt;, 2026).&lt;/p&gt;

&lt;p&gt;We'd also hold off in three other situations. If you're mid-upgrade and not yet stable on Laravel 13, finish the upgrade first; mixing a version jump with a style migration doubles your rollback ambiguity when something breaks. If your test coverage on models and listeners is thin, write the tests first, because a mechanical refactor without a safety net is just risk with extra steps. And if you maintain a package supporting both Laravel 12 and 13, stay property-based until you drop 12, which is still a supported release and will be for a while.&lt;/p&gt;

&lt;p&gt;Ask one question: will a human edit this code in the next year? If yes, the refactor buys you compounding readability every time someone opens the file. If no, leave it alone. Refactoring is an investment, and investments need a return window. The broader trend data in our &lt;a href="https://deploynix.io/blog/the-state-of-laravel-deployment-in-2026-whats-changed-and-what-still-hurts" rel="noopener noreferrer"&gt;state of Laravel deployment in 2026 report&lt;/a&gt; suggests most actively developed apps hit that bar easily, while a long tail of maintenance-mode apps never will, and that's fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Pragmatic Migration Plan for a Production Codebase
&lt;/h2&gt;

&lt;p&gt;Here's the plan we used on our own app and now recommend to customers. Total elapsed time for us: about three weeks of background work, never blocking feature development, across roughly 80 models, 60 listeners, and 40 notifications.&lt;/p&gt;

&lt;p&gt;Phase&lt;/p&gt;

&lt;p&gt;Scope&lt;/p&gt;

&lt;p&gt;Safety net&lt;/p&gt;

&lt;p&gt;Deploy size&lt;/p&gt;

&lt;p&gt;1&lt;/p&gt;

&lt;p&gt;One low-risk model namespace (e.g. &lt;code&gt;App\Models\Billing&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Existing feature tests + serialization snapshot tests&lt;/p&gt;

&lt;p&gt;Single small deploy&lt;/p&gt;

&lt;p&gt;2&lt;/p&gt;

&lt;p&gt;Remaining model namespaces, one per PR&lt;/p&gt;

&lt;p&gt;Rector draft, hand review, full suite per PR&lt;/p&gt;

&lt;p&gt;One namespace per deploy&lt;/p&gt;

&lt;p&gt;3&lt;/p&gt;

&lt;p&gt;Listeners + EventServiceProvider teardown&lt;/p&gt;

&lt;p&gt;Event fake assertions on every event/listener pair&lt;/p&gt;

&lt;p&gt;Two deploys&lt;/p&gt;

&lt;p&gt;4&lt;/p&gt;

&lt;p&gt;Notifications, mailables, broadcast events&lt;/p&gt;

&lt;p&gt;Notification fakes + a staging click-through&lt;/p&gt;

&lt;p&gt;Two or three deploys&lt;/p&gt;

&lt;p&gt;5&lt;/p&gt;

&lt;p&gt;Stub updates + written convention in CONTRIBUTING&lt;/p&gt;

&lt;p&gt;CI lint rule rejecting new property-style config&lt;/p&gt;

&lt;p&gt;One deploy&lt;/p&gt;

&lt;p&gt;Three principles make this boring, in the good sense. First, one namespace at a time. A repo-wide PR touching 200 files is unreviewable, and unreviewable PRs are where mechanical refactors go wrong. A 12-file PR gets real review in ten minutes.&lt;/p&gt;

&lt;p&gt;Second, tests are the contract. Before converting a model, we made sure something asserted its mass-assignment behavior and its serialized shape, because &lt;code&gt;$fillable&lt;/code&gt; and &lt;code&gt;$hidden&lt;/code&gt; mistakes are silent by design. A cheap trick: a snapshot test that serializes one factory instance of every model and diffs the keys. It caught our only real conversion bug, a &lt;code&gt;Hidden&lt;/code&gt; attribute that listed &lt;code&gt;api_token&lt;/code&gt; while the property had said &lt;code&gt;api_token_hash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Third, deploy in small batches, quickly. Don't let five converted namespaces pile up unreleased. Each phase should reach production within a day of merging, so if anything slips through, the suspect list is one small diff, not three weeks of them. This is standard advice for any refactor, but doubly so for one that changes how configuration is read rather than what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping the Refactor on Deploynix
&lt;/h2&gt;

&lt;p&gt;We ran this exact migration on Deploynix itself, using Deploynix, so here's the concrete workflow rather than the theory. Nothing below is specific to attributes; it's how we ship any large mechanical refactor where the blast radius is "every model in the app."&lt;/p&gt;

&lt;p&gt;Each phase branch went through our staging-to-production pipeline. A push to the branch triggered a staging deploy via the GitHub integration, and staging ran the same PHP version as production, pinned per site, which mattered during the window when production was on PHP 8.3 and we were validating 8.5. On staging we ran the full Pest suite plus the serialization snapshot check against a production-shaped database, then let the branch soak for a few hours while monitoring watched error rates and queue depth.&lt;/p&gt;

&lt;p&gt;Production deploys used the standard release model: each deploy builds into a fresh release directory, runs &lt;code&gt;composer install&lt;/code&gt;, &lt;code&gt;php artisan optimize&lt;/code&gt;, and migrations, and then goes live through an atomic symlink switch. Zero downtime, and critically for a refactor like this, the previous release stays on disk untouched. When our Phase 3 deploy surfaced a listener that Rector had converted but whose queue name attribute pointed at a queue Horizon wasn't consuming, we rolled back with one click, the symlink flipped to the prior release, and total customer-facing weirdness lasted under a minute. We fixed the queue name, redeployed, done. That failure would have been a tense evening with a rsync-based deploy; with releases and instant rollback it was a Slack message. The mechanics are the same ones we describe in &lt;a href="https://deploynix.io/blog/how-to-roll-back-a-failed-deployment-in-30-seconds" rel="noopener noreferrer"&gt;how to roll back a failed deployment in 30 seconds&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The lesson we'd generalize: large mechanical refactors are only as scary as your rollback story. If reverting is instant and boring, you can ship a 12-file conversion PR every afternoon without ceremony, and the whole migration becomes background noise. If reverting is a 40-minute manual procedure, you'll batch changes into big risky releases, which is exactly backwards. Get the deployment mechanics right first, whatever platform or scripts you use, and the refactor itself becomes the easy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I have to migrate to attributes when upgrading to Laravel 13?
&lt;/h3&gt;

&lt;p&gt;No. Laravel 13 ships with no app-code breaking changes, and property-based configuration like &lt;code&gt;$fillable&lt;/code&gt; and &lt;code&gt;$table&lt;/code&gt; continues to work with no deprecation warnings (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;, 2026). The upgrade and the refactor are separate projects. Do the upgrade first, stabilize, then migrate styles at your own pace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can attributes and properties coexist in the same model?
&lt;/h3&gt;

&lt;p&gt;Yes, and mixing is officially supported. A model can use &lt;code&gt;#[Table]&lt;/code&gt; and &lt;code&gt;#[Fillable]&lt;/code&gt; while keeping a &lt;code&gt;casts()&lt;/code&gt; method for dynamic casts (&lt;a href="https://www.krishaweb.com/blog/laravel-13-key-features/" rel="noopener noreferrer"&gt;KrishaWeb&lt;/a&gt;, 2026). If both styles define the same setting, the attribute wins, but we'd treat that situation as a bug and let a lint rule catch it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the minimum PHP version for Laravel 13's attributes?
&lt;/h3&gt;

&lt;p&gt;Laravel 13 requires PHP 8.3 or newer (&lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;, 2026). The attribute syntax itself dates back to PHP 8.0, so the constraint is the framework's floor, not the language feature. With PHP 8.5 now stable, we'd provision new servers on 8.4 or 8.5 and pin per site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do attributes slow down requests since they use reflection?
&lt;/h3&gt;

&lt;p&gt;No. Attribute metadata is read via reflection once and cached alongside Laravel's existing bootstrap and metadata caches, so per-request cost is effectively zero (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;, 2026). Our own production benchmarks showed no measurable p50 difference across 80 converted models. Just keep &lt;code&gt;php artisan optimize&lt;/code&gt; in your deploy pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Rector automate the conversion?
&lt;/h3&gt;

&lt;p&gt;Mostly. Rector's Laravel rule set converts the mechanical cases: &lt;code&gt;$table&lt;/code&gt;, &lt;code&gt;$fillable&lt;/code&gt;, &lt;code&gt;$hidden&lt;/code&gt;, static casts, and listener registrations. Treat its output as a draft, review every diff, and leave dynamic configuration, like tenant-dependent casts or preference-based notification channels, in methods where it belongs. Run it per namespace, not repo-wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;Attributes won't change what your app does. They change how quickly the next engineer understands it, and over a codebase's lifetime that's the cost that dominates. Laravel 13 made the modern style available everywhere without forcing anyone's hand, which is exactly the right way to evolve a 15-year-old framework. Our advice: don't schedule a heroic rewrite. Pick one quiet model namespace this sprint, convert it behind your test suite, ship it in a small deploy, and let the pattern spread PR by PR from there.&lt;/p&gt;

&lt;p&gt;If you want the fuller operational picture first, from server provisioning through release strategy, our &lt;a href="https://deploynix.io/blog/the-definitive-guide-to-laravel-deployment-in-2026" rel="noopener noreferrer"&gt;definitive guide to Laravel deployment in 2026&lt;/a&gt; covers the pipeline foundations that make refactors like this one routine instead of risky. Start there, then go slim down that 60-line model header. It's been waiting long enough.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>laravel13</category>
      <category>php</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>PHP 8.5 in Production: The Pipe Operator, and Upgrading PHP-FPM Without Downtime</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sun, 09 Aug 2026 15:12:18 +0000</pubDate>
      <link>https://dev.to/deploynix/php-85-in-production-the-pipe-operator-and-upgrading-php-fpm-without-downtime-50og</link>
      <guid>https://dev.to/deploynix/php-85-in-production-the-pipe-operator-and-upgrading-php-fpm-without-downtime-50og</guid>
      <description>&lt;p&gt;PHP 8.5 was released in November 2025 with the pipe operator, backtraces on fatal errors, closures in constant expressions, and the long-overdue &lt;code&gt;array_first()&lt;/code&gt; and &lt;code&gt;array_last()&lt;/code&gt; functions (&lt;a href="https://www.phoronix.com/news/PHP-8.5-Released" rel="noopener noreferrer"&gt;Phoronix&lt;/a&gt;). Nine months later, in August 2026, the ecosystem has mostly caught up. Laravel, Symfony, and WordPress all run on it, and the major extension maintainers have shipped compatible builds (&lt;a href="https://www.julienturbide.com/blog/php-8-5-adoption-guide" rel="noopener noreferrer"&gt;adoption guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;So the question for most teams is no longer "does it work?" It's two more practical questions. First: which 8.5 features actually earn their place in a Laravel codebase, and which are syntax novelty? Second: how do you move a production PHP-FPM fleet from 8.4 to 8.5 without dropping a single request, and with a rollback you can execute in under a minute if something breaks?&lt;/p&gt;

&lt;p&gt;We've upgraded a lot of servers through PHP version transitions, and we've watched the same mistakes repeat: in-place upgrades that remove the old runtime, extension mismatches discovered at 2 a.m., and queue workers silently running a different PHP version than the web tier. This post covers both halves: an honest review of what's useful in 8.5 for Laravel developers, and the exact Ubuntu 24.04 playbook for a side-by-side upgrade with instant rollback.&lt;/p&gt;

&lt;p&gt;If you followed our &lt;a href="https://deploynix.io/blog/php-84-in-production-new-features-that-make-your-laravel-app-faster" rel="noopener noreferrer"&gt;PHP 8.4 upgrade guide&lt;/a&gt; last year, the mechanics here will feel familiar. The features, though, are a bigger deal this time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- PHP 8.5 (released November 2025) ships the pipe operator, fatal error backtraces, &lt;code&gt;array_first()&lt;/code&gt;/&lt;code&gt;array_last()&lt;/code&gt;, and constant expression improvements (Phoronix) - Fatal error backtraces alone justify the upgrade for production debugging - Adopt the pipe operator in greenfield code with an agreed style; hold off in legacy codebases - Upgrade side-by-side: install 8.5 next to 8.4, cut over per site at the nginx level, keep 8.4 for instant rollback&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Did PHP 8.5 Actually Ship?
&lt;/h2&gt;

&lt;p&gt;PHP 8.5 landed on November 20, 2025 with five headline items: the pipe operator (&lt;code&gt;|&amp;gt;&lt;/code&gt;), backtraces on fatal errors, closures and first-class callables in constant expressions, casts in constant expressions, and &lt;code&gt;array_first()&lt;/code&gt;/&lt;code&gt;array_last()&lt;/code&gt; (&lt;a href="https://www.phoronix.com/news/PHP-8.5-Released" rel="noopener noreferrer"&gt;Phoronix&lt;/a&gt;). That's a heavier feature list than 8.4's, which leaned on property hooks and asymmetric visibility.&lt;/p&gt;

&lt;p&gt;Here's the short version of what matters for a Laravel application, before we go deeper on each:&lt;/p&gt;

&lt;p&gt;Feature&lt;/p&gt;

&lt;p&gt;What it does&lt;/p&gt;

&lt;p&gt;Production impact&lt;/p&gt;

&lt;p&gt;Pipe operator `\&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;`&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Passes the left expression as the first argument to the right callable (&lt;a href="https://www.zend.com/blog/php-pipe-operator" rel="noopener noreferrer"&gt;Zend&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Readability for transform chains; zero runtime cost vs nested calls&lt;/p&gt;

&lt;p&gt;Fatal error backtraces&lt;/p&gt;

&lt;p&gt;Fatal errors (OOM, timeouts) now include a stack trace&lt;/p&gt;

&lt;p&gt;Big. Turns "which request died?" into "which line died?"&lt;/p&gt;

&lt;p&gt;&lt;code&gt;array_first()&lt;/code&gt; / &lt;code&gt;array_last()&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;First/last value without touching the internal pointer&lt;/p&gt;

&lt;p&gt;Removes a class of &lt;code&gt;reset()&lt;/code&gt;/&lt;code&gt;end()&lt;/code&gt; footguns&lt;/p&gt;

&lt;p&gt;Closures in constant expressions&lt;/p&gt;

&lt;p&gt;Static closures and first-class callables in defaults, constants, attributes&lt;/p&gt;

&lt;p&gt;Cleaner attribute-driven validation and config&lt;/p&gt;

&lt;p&gt;Casts in constant expressions&lt;/p&gt;

&lt;p&gt;&lt;code&gt;(int)&lt;/code&gt;, &lt;code&gt;(string)&lt;/code&gt; etc. allowed in &lt;code&gt;const&lt;/code&gt; definitions&lt;/p&gt;

&lt;p&gt;Minor, but removes awkward workarounds&lt;/p&gt;

&lt;p&gt;Notice what's not on the list: there's no single "your app gets 20% faster" feature this cycle. The 8.5 release is about ergonomics and observability, not raw throughput. If you're chasing performance, your time is still better spent on &lt;a href="https://deploynix.io/blog/opcache-configuration-for-laravel-the-free-performance-boost-youre-ignoring" rel="noopener noreferrer"&gt;OPcache configuration&lt;/a&gt; than on any syntax in this release.&lt;/p&gt;

&lt;p&gt;One version-support fact to anchor the rest of this post: as of August 2026, PHP 8.5 is the current stable release, 8.4 is the previous release, and Laravel 13 (current since March 2026) requires PHP 8.3 as its minimum. So every supported Laravel version runs happily on 8.5. There's no framework reason to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does the Pipe Operator Work in Real Laravel Code?
&lt;/h2&gt;

&lt;p&gt;The pipe operator takes the expression on its left and passes it as the first argument to the callable on its right (&lt;a href="https://www.zend.com/blog/php-pipe-operator" rel="noopener noreferrer"&gt;Zend&lt;/a&gt;). Each stage must be a callable that accepts one argument, which is why you'll usually see first-class callable syntax (&lt;code&gt;trim(...)&lt;/code&gt;) or short closures in a chain (&lt;a href="[https://www.phparch.com/2026/06/php-8-5-pipe-operator/](https://www.phparch.com/2026/06/php-8-5-pipe-operator/)"&gt;php[architect]&lt;/a&gt;). That's the entire semantic. No magic, no autoloading tricks, no runtime dispatch overhead beyond a normal function call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collection-style transforms without Collection overhead
&lt;/h3&gt;

&lt;p&gt;Laravel developers already think in pipelines. We reach for &lt;code&gt;collect()&lt;/code&gt; or &lt;code&gt;Str::of()&lt;/code&gt; even for three-step string transforms, because chained methods read better than nested calls. The pipe operator gives you that reading order on plain values, without allocating a &lt;code&gt;Collection&lt;/code&gt; or &lt;code&gt;Stringable&lt;/code&gt; object per step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;squish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare the pre-8.5 equivalents. Nested calls read inside-out: &lt;code&gt;Str::slug(Str::squish(trim($title)))&lt;/code&gt;. The fluent version, &lt;code&gt;Str::of($title)-&amp;gt;squish()-&amp;gt;slug()-&amp;gt;value()&lt;/code&gt;, reads fine but allocates intermediate objects. In a hot path that runs thousands of times per request, say normalizing rows in an import, the pipe version has the readability of the fluent API with the cost profile of the nested one.&lt;/p&gt;

&lt;p&gt;Is that overhead ever your actual bottleneck? Honestly, almost never. Choose pipes for readability first and treat the allocation savings as a bonus.&lt;/p&gt;

&lt;h3&gt;
  
  
  Normalizing request data
&lt;/h3&gt;

&lt;p&gt;The other place pipes shine is input normalization, where each step is a small, testable transform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[^0-9+]/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'00'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'+'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage does one thing, in reading order. When a bug report says "phone numbers starting with 00 aren't converting", you know exactly which line to look at. With a single dense &lt;code&gt;preg_replace&lt;/code&gt; plus ternary soup, you don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where pipes get ugly
&lt;/h3&gt;

&lt;p&gt;The pipe operator only passes one argument, always in the first position. The moment a function wants your value in the second position, &lt;code&gt;explode()&lt;/code&gt; is the classic offender, you're wrapping it in a closure anyway. Chains that are 80% closure wrappers read worse than the code they replaced. Multi-argument stages, conditional branches mid-chain, and anything with side effects belong in a named method, not a pipe. Our rule of thumb: if more than one stage in the chain needs a closure with a body longer than one expression, refactor to a method instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are array_first() and array_last() Worth Caring About?
&lt;/h2&gt;

&lt;p&gt;Yes, more than they look. PHP 8.5 added &lt;code&gt;array_first()&lt;/code&gt; and &lt;code&gt;array_last()&lt;/code&gt; as part of the same release (&lt;a href="https://www.phoronix.com/news/PHP-8.5-Released" rel="noopener noreferrer"&gt;Phoronix&lt;/a&gt;), and they replace two of the oldest footguns in the language: &lt;code&gt;reset()&lt;/code&gt; and &lt;code&gt;end()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem with the old functions is that they take their argument by reference and mutate the array's internal pointer. That has two consequences you've probably hit. You can't call them on a function return value without a "notice: only variables should be passed by reference", so you create a throwaway variable. And they return &lt;code&gt;false&lt;/code&gt; for an empty array, which is indistinguishable from a stored &lt;code&gt;false&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: temp variable, pointer mutation, false-vs-empty ambiguity&lt;/span&gt;
&lt;span class="nv"&gt;$errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$validator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$firstError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$errors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// PHP 8.5: direct, no mutation, null on empty&lt;/span&gt;
&lt;span class="nv"&gt;$firstError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;array_first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;array_first()&lt;/code&gt; returns the first value or &lt;code&gt;null&lt;/code&gt; if the array is empty, and it never touches the internal pointer. In Laravel code you already have &lt;code&gt;Arr::first()&lt;/code&gt;, so the practical win is smaller than in framework-free code. But native functions work in constant expressions, in packages that avoid the framework, and without the helper's closure-support overhead. It's also one less place where new team members ask "wait, why is there a &lt;code&gt;reset()&lt;/code&gt; here?"&lt;/p&gt;

&lt;p&gt;Small feature, real quality-of-life gain. Nobody upgrades for this, but everybody uses it within a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do Fatal Error Backtraces Change Production Debugging?
&lt;/h2&gt;

&lt;p&gt;This is the sleeper feature of the release, and in our experience it's the strongest single argument for upgrading production servers. PHP 8.5 adds backtraces to fatal errors (&lt;a href="https://www.phoronix.com/news/PHP-8.5-Released" rel="noopener noreferrer"&gt;Phoronix&lt;/a&gt;), controlled by the new &lt;code&gt;fatal_error_backtraces&lt;/code&gt; ini setting, which is enabled by default.&lt;/p&gt;

&lt;p&gt;Before 8.5, the two most common production killers, memory exhaustion and max execution timeouts, died with a single line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP Fatal error: Allowed memory size of 536870912 bytes exhausted
(tried to allocate 262144 bytes) in
/var/www/app/vendor/laravel/framework/src/Illuminate/Support/Collection.php on line 138
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tells you a &lt;code&gt;Collection&lt;/code&gt; method allocated the final straw. It tells you nothing about which controller, job, or command built the collection that ate 512 MB. Teams have historically debugged these by binary-searching log timestamps against access logs. On 8.5, the same failure logs a full stack trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP Fatal error: Allowed memory size of 536870912 bytes exhausted ...
Stack trace:
#0 /var/www/app/app/Services/ReportBuilder.php(88): Illuminate\Support\Collection-&amp;gt;map()
#1 /var/www/app/app/Jobs/GenerateMonthlyReport.php(41): App\Services\ReportBuilder-&amp;gt;build()
#2 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you know it's the monthly report job, and you know it's the &lt;code&gt;map()&lt;/code&gt; call on line 88 hydrating too many models at once. What used to be an afternoon of archaeology is now a two-minute read of the FPM error log. Since these traces land in &lt;code&gt;php8.5-fpm&lt;/code&gt;'s error log, this pairs naturally with log-based alerting: if your monitoring tails FPM logs, your alerts just got dramatically more actionable.&lt;/p&gt;

&lt;p&gt;One caveat worth knowing: building a backtrace during memory exhaustion requires a small reserved buffer, and in pathological OOM cases the trace can be truncated. A truncated trace still beats no trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in Constant Expressions?
&lt;/h2&gt;

&lt;p&gt;PHP 8.5 allows closures and first-class callables in constant expressions, and it allows casts there too (&lt;a href="https://www.phoronix.com/news/PHP-8.5-Released" rel="noopener noreferrer"&gt;Phoronix&lt;/a&gt;). "Constant expressions" means the places PHP evaluates at compile time: class constants, default parameter values, property defaults, and attribute arguments.&lt;/p&gt;

&lt;p&gt;The practical wins are in defaults and attributes. Before 8.5, a parameter couldn't default to a closure, so you wrote nullable-plus-fallback boilerplate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before 8.5&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?Closure&lt;/span&gt; &lt;span class="nv"&gt;$cleaner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cleaner&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cleaner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// PHP 8.5&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$cleaner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cleaner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attributes gain the most. Validation and mapping attributes can now carry behavior instead of just configuration strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WebhookPayload&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[EnsureThat(static fn (mixed $v): bool =&amp;gt; is_string($v) &amp;amp;&amp;amp; $v !== '')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only static closures are allowed (no &lt;code&gt;$this&lt;/code&gt; capture, no &lt;code&gt;use&lt;/code&gt; imports), which is the right constraint: compile-time values shouldn't depend on runtime state. Casts in constant expressions are a smaller courtesy, so &lt;code&gt;public const int TIMEOUT_MS = (int) 2.5e3;&lt;/code&gt; now just works instead of forcing you to precompute the literal.&lt;/p&gt;

&lt;p&gt;Will you use this daily? Probably not. Package authors will, though, and you'll feel it in cleaner APIs from the validation and serialization libraries you depend on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Adopt PHP 8.5 in 2026?
&lt;/h2&gt;

&lt;p&gt;For the runtime itself: yes. Laravel, Symfony, and WordPress are all compatible, and by mid-2026 the ecosystem has had three release cycles to shake out extension issues (&lt;a href="https://www.julienturbide.com/blog/php-8-5-adoption-guide" rel="noopener noreferrer"&gt;adoption guide&lt;/a&gt;). The fatal error backtraces and the security-support clock both push in the same direction. Running the current stable release means five more years before your next forced migration.&lt;/p&gt;

&lt;p&gt;The syntax is a separate decision, and this is where we'd urge some restraint. The adoption guidance that's emerged in 2026 matches our experience: the pipe operator is worth adopting in greenfield projects where the team agrees on a style up front, and worth holding off on in legacy codebases where reviewers don't read it fluently yet (&lt;a href="https://www.julienturbide.com/blog/php-8-5-adoption-guide" rel="noopener noreferrer"&gt;adoption guide&lt;/a&gt;). A codebase where 5% of transforms use pipes and 95% use fluent chains isn't more modern, it's just less consistent. Style consistency beats syntax novelty every time someone new reads your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Fatal error backtraces improve production debugging immediately with zero code changes. The pipe operator and &lt;code&gt;array_first()&lt;/code&gt; remove real friction. Full framework compatibility means no blocker for Laravel 13 apps, and upgrading now resets your security-support window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams already on PHP 8.4 with a green test suite, greenfield projects that can set pipe-operator conventions from day one, and anyone planning a &lt;a href="https://deploynix.io/blog/upgrading-to-laravel-13-in-production-with-zero-downtime" rel="noopener noreferrer"&gt;Laravel 13 upgrade&lt;/a&gt; who'd rather do one runtime migration than two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Legacy codebases gain little from new syntax until the whole team reads it fluently. Niche PECL extensions may still lag (more on that below). And if you're on PHP 8.2 or earlier, jumping two-plus versions at once multiplies deprecation risk; step through 8.4 first.&lt;/p&gt;

&lt;p&gt;If the runtime answer is yes, the remaining question is purely operational. So let's do the upgrade properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Zero-Downtime PHP-FPM Upgrade Playbook (Ubuntu 24.04)
&lt;/h2&gt;

&lt;p&gt;The entire playbook rests on one architectural decision: install PHP 8.5 alongside 8.4, never on top of it. Ubuntu's ondrej/php PPA packages every PHP version with its own binaries, config tree, FPM service, and socket, specifically so multiple versions coexist. Your cutover then becomes a one-line nginx change, and your rollback is the same line reversed.&lt;/p&gt;

&lt;p&gt;Why not upgrade in place? Let's compare honestly.&lt;/p&gt;

&lt;p&gt;In-place upgrade&lt;/p&gt;

&lt;p&gt;Side-by-side install&lt;/p&gt;

&lt;p&gt;Downtime&lt;/p&gt;

&lt;p&gt;Seconds to minutes while FPM swaps&lt;/p&gt;

&lt;p&gt;Zero (graceful nginx reload)&lt;/p&gt;

&lt;p&gt;Rollback&lt;/p&gt;

&lt;p&gt;Reinstall 8.4, restore configs&lt;/p&gt;

&lt;p&gt;Revert one nginx line, reload&lt;/p&gt;

&lt;p&gt;Disk/memory cost&lt;/p&gt;

&lt;p&gt;None&lt;/p&gt;

&lt;p&gt;~150 MB disk, one mostly idle FPM master&lt;/p&gt;

&lt;p&gt;Per-site cutover&lt;/p&gt;

&lt;p&gt;No, all sites move at once&lt;/p&gt;

&lt;p&gt;Yes, migrate one site at a time&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; In-place is simpler and leaves nothing to clean up. Side-by-side gives zero downtime, per-site granularity, and a sub-minute rollback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; In-place suits throwaway or single-tenant staging boxes. Side-by-side is the right call for any production server, and it's non-negotiable for servers hosting multiple sites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Side-by-side means two config trees to keep in sync until you retire 8.4, and it's easy to forget that cron jobs and workers use the CLI binary, which cuts over separately from the web tier.&lt;/p&gt;

&lt;p&gt;Side-by-side wins for production. Here's the sequence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Install 8.5 next to 8.4
&lt;/h3&gt;

&lt;p&gt;On Ubuntu 24.04 with the ondrej/php PPA (add it first if this server doesn't have it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;add-apt-repository ppa:ondrej/php &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; php8.5-fpm php8.5-&lt;span class="o"&gt;{&lt;/span&gt;mysql,redis,mbstring,xml,curl,zip,gd,intl,bcmath&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; php8.5-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You now have two FPM masters running: &lt;code&gt;php8.4-fpm&lt;/code&gt; listening on &lt;code&gt;/run/php/php8.4-fpm.sock&lt;/code&gt; and &lt;code&gt;php8.5-fpm&lt;/code&gt; on &lt;code&gt;/run/php/php8.5-fpm.sock&lt;/code&gt;. Each has its own pool config under &lt;code&gt;/etc/php/8.5/fpm/pool.d/&lt;/code&gt;. Copy over any pool tuning you've done for 8.4, &lt;code&gt;pm.max_children&lt;/code&gt;, &lt;code&gt;pm.max_requests&lt;/code&gt;, memory limits, because the 8.5 packages ship stock defaults. If you haven't tuned pools before, our &lt;a href="https://deploynix.io/blog/tuning-php-fpm-for-laravel-workers-memory-and-process-management" rel="noopener noreferrer"&gt;PHP-FPM tuning guide for Laravel&lt;/a&gt; covers the sizing math.&lt;/p&gt;

&lt;p&gt;An idle FPM master costs a few megabytes of memory. Running both for weeks is fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Audit extension parity
&lt;/h3&gt;

&lt;p&gt;The single most common upgrade failure isn't a language change, it's a missing extension. The 8.4 install accumulated extensions over years; the fresh 8.5 install has only what you just listed. Diff them:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
bash
diff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>php</category>
      <category>phpfpm</category>
      <category>deployment</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building an MCP Server in Laravel: Let AI Agents Talk to Your App Safely</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sun, 09 Aug 2026 15:06:55 +0000</pubDate>
      <link>https://dev.to/deploynix/building-an-mcp-server-in-laravel-let-ai-agents-talk-to-your-app-safely-gid</link>
      <guid>https://dev.to/deploynix/building-an-mcp-server-in-laravel-let-ai-agents-talk-to-your-app-safely-gid</guid>
      <description>&lt;p&gt;Picture a support engineer pasting a customer email into Claude and asking, "Why did this customer's last deployment fail?" Six months ago, the answer involved tab-switching between an admin panel, a log viewer, and a database client. Now the agent calls a tool named &lt;code&gt;list-recent-deployments&lt;/code&gt;, reads the failed step's output through a second tool, and drafts a reply with the actual error attached. Nobody exported a CSV. Nobody ran a raw SQL query against production. The agent talked to the Laravel app directly, through a narrow, authenticated, audited interface that we designed for it.&lt;/p&gt;

&lt;p&gt;That interface is an MCP server. The Model Context Protocol was introduced by Anthropic as an open standard in November 2024, and the interoperability story is the whole point: any MCP-compatible client, whether Claude, Cursor, or GitHub Copilot, can connect to any MCP server (&lt;a href="https://laravel.com/docs/12.x/mcp" rel="noopener noreferrer"&gt;Laravel MCP docs&lt;/a&gt;, &lt;a href="https://laravel.com/blog/laravel-mcp-a-complete-guide" rel="noopener noreferrer"&gt;Laravel's MCP guide&lt;/a&gt;). Laravel shipped first-party support through the official &lt;code&gt;laravel/mcp&lt;/code&gt; package (&lt;a href="https://packagist.org/packages/laravel/mcp" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;), so you define tools the same way you define controllers, jobs, and policies: as small classes with validation and authorization built in.&lt;/p&gt;

&lt;p&gt;Here's the tension, though. An MCP server is an API that a probabilistic system calls on behalf of a human. Agents in 2026 aren't autocomplete anymore; they run for minutes or hours and get delegated whole tasks (&lt;a href="https://thenewstack.io/5-key-trends-shaping-agentic-development-in-2026/" rel="noopener noreferrer"&gt;The New Stack&lt;/a&gt;). An agent with a badly scoped tool will eventually do something you didn't intend, not out of malice, but out of statistics. So this guide treats security as the backbone, not an afterthought. We'll build a working MCP server on Laravel 13 and PHP 8.5, then spend most of our time on the parts that keep it from becoming your most dangerous endpoint.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways - &lt;code&gt;laravel/mcp&lt;/code&gt; is the official first-party package; tools are classes with schemas, validation, and authorization (Packagist). - Any MCP client (Claude, Cursor, Copilot) can connect to any MCP server, per the open standard Anthropic released in November 2024. - Treat agents as untrusted callers: Sanctum auth, per-tool policies, rate limits, audit logs, and confirmation gates for anything destructive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why Would You Let AI Agents Talk to Your Laravel App at All?
&lt;/h2&gt;

&lt;p&gt;The honest answer: because your team is already using agents, and the alternative is worse. When an agent can't reach your app through a sanctioned interface, people improvise. They paste production data into chat windows. They hand agents database credentials "just for this one query." They screenshot admin panels. An MCP server replaces that improvisation with an interface you control, validate, and log.&lt;/p&gt;

&lt;p&gt;We see three use cases that justify the effort, and we've built for all three internally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support tooling&lt;/strong&gt; is the easiest win. A support agent (human or AI) needs read access to a customer's recent activity: orders, deployments, invoices, error events. These are read-only queries with obvious tenant boundaries. An MCP tool that accepts a customer ID, checks authorization, and returns a structured summary saves hours per week and leaks nothing beyond what the caller was already allowed to see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal ops&lt;/strong&gt; is the second tier. Think "requeue this failed job," "resend this webhook," or "toggle this feature flag for one team." These are write operations, but they're small, reversible, and already exposed in your admin panel. Wrapping them in MCP tools means an on-call engineer can ask an agent to triage an incident at 2 a.m. instead of clicking through five screens half-asleep. In our experience, the audit trail actually improves here, because every tool call is logged with arguments, while admin-panel clicks often aren't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer-facing agent features&lt;/strong&gt; are the third tier and the highest stakes. If your product exposes an MCP server to customers, their agents can integrate your product into their workflows without you building a bespoke plugin for every client. This is where MCP's client-agnostic design pays off: you build one server, and Claude, Cursor, and Copilot users all get the integration for free. It's also where every security control in this article stops being optional.&lt;/p&gt;

&lt;p&gt;What about the counterargument, that a REST API already covers this? [UNIQUE INSIGHT] A REST API is documentation-shaped: a human reads the docs, writes glue code, handles pagination, and ships an integration. An MCP server is decision-shaped: it hands the client a menu of typed, described actions that a model can choose between mid-conversation, with no glue code in between. You'll likely want both, and they'll share the same policies underneath. The difference is who the consumer is and how much ceremony sits between intent and execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are MCP Tools, Resources, and Prompts?
&lt;/h2&gt;

&lt;p&gt;MCP defines three primitives a server can expose, and picking the right one for each capability matters more than it first appears. Getting this wrong usually means shipping a tool that should have been a resource, which inflates your writable surface for no benefit.&lt;/p&gt;

&lt;p&gt;Primitive&lt;/p&gt;

&lt;p&gt;Direction&lt;/p&gt;

&lt;p&gt;What it's for&lt;/p&gt;

&lt;p&gt;Laravel analogy&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Agent calls it with arguments&lt;/p&gt;

&lt;p&gt;Actions and parameterized queries: "create X," "list Y for Z"&lt;/p&gt;

&lt;p&gt;A controller action with a Form Request&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Agent reads it&lt;/p&gt;

&lt;p&gt;Reference material: docs, config, schema descriptions, reports&lt;/p&gt;

&lt;p&gt;A read-only route serving a document&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Agent requests a template&lt;/p&gt;

&lt;p&gt;Reusable, server-authored instructions for common workflows&lt;/p&gt;

&lt;p&gt;A Blade template for model instructions&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools&lt;/strong&gt; are the workhorses. Each tool has a name, a natural-language description, and a typed argument schema. The description is not decoration: it's how the model decides when to call the tool, so write it like you'd write for a sharp junior engineer who skims. Vague descriptions produce misfired calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resources&lt;/strong&gt; are content the agent can pull into context: your API changelog, a runbook, a schema reference. If a capability takes no arguments and changes nothing, make it a resource, not a tool. Fewer tools means fewer wrong choices for the model and a smaller surface for you to authorize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompts&lt;/strong&gt; are server-provided templates. If every "triage an incident" conversation should start with the same instructions and the same data-gathering sequence, ship that as a prompt so users don't reinvent it badly. We've found that prompts are the most underused primitive; teams put workflow instructions in a wiki that agents never read, when the server itself could serve them.&lt;/p&gt;

&lt;p&gt;One design rule we'd push hard: model your tools around intents, not tables. &lt;code&gt;resend-failed-webhook&lt;/code&gt; is a good tool. &lt;code&gt;update-webhook-row&lt;/code&gt; is a bad one, because it forces the model to understand your schema and gives it write access far beyond the intent. Narrow tools are easier to authorize, easier to describe, and much harder to misuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Build One with laravel/mcp?
&lt;/h2&gt;

&lt;p&gt;The official package makes this feel like normal Laravel work, which is exactly what you want. Install it and publish the routes file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/mcp
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ai-routes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you &lt;code&gt;routes/ai.php&lt;/code&gt;, a dedicated routes file for MCP servers, deliberately separate from &lt;code&gt;web.php&lt;/code&gt; and &lt;code&gt;api.php&lt;/code&gt;. Then generate a server and a first tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan make:mcp-server OpsServer
php artisan make:mcp-tool ListRecentDeployments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Defining the server
&lt;/h3&gt;

&lt;p&gt;A server class declares its identity, its instructions to connecting clients, and its capabilities. Instructions matter: they're the system-level guidance every client receives, so use them to state what the server is for and what it will refuse to do.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>laravel</category>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>Laravel Reverb's New Database Driver: Real-Time WebSockets Without Redis</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Mon, 03 Aug 2026 07:38:01 +0000</pubDate>
      <link>https://dev.to/deploynix/laravel-reverbs-new-database-driver-real-time-websockets-without-redis-19fp</link>
      <guid>https://dev.to/deploynix/laravel-reverbs-new-database-driver-real-time-websockets-without-redis-19fp</guid>
      <description>&lt;p&gt;Until this year, every Laravel team that wanted to scale Reverb past a single process ended up having the same conversation. Someone would say "we just need a second Reverb worker," and someone else would answer "then we need Redis." Not because the app used Redis for anything else, but because Reverb's scaling mode only spoke Redis pub/sub. For a lot of small and mid-sized apps, that meant installing, securing, and monitoring an entire extra service to pass a few hundred messages per minute between two PHP processes.&lt;/p&gt;

&lt;p&gt;Laravel 13, released on March 17, 2026, quietly removed that requirement. The release added a database driver to Reverb's scaling layer, so real-time features can now run across multiple processes without a Redis dependency, which meaningfully simplifies infrastructure for smaller deployments (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;, &lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;). Your MySQL or PostgreSQL server, the one you already back up and monitor, becomes the message bus.&lt;/p&gt;

&lt;p&gt;That raises a genuinely interesting question: when should you use it? The database driver isn't a free lunch, and Redis (or Valkey) still wins in specific scenarios. We run a lot of Reverb in production for our own platform and for customers, and in this post we'll walk through how Reverb scaling actually works, what the new driver changes, an honest decision framework for choosing between the three options, and the full production setup either way. If you're newer to this topic, our &lt;a href="https://deploynix.io/blog/websockets-101-for-laravel-developers-from-concept-to-production-on-deploynix" rel="noopener noreferrer"&gt;WebSockets primer for Laravel developers&lt;/a&gt; covers the fundamentals before you get into scaling questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Reverb Scaling Works Under the Hood
&lt;/h2&gt;

&lt;p&gt;To understand why the database driver matters, you need to understand what problem the scaling layer solves in the first place. Reverb is a long-running PHP process built on an event loop. When a browser opens a WebSocket connection, that connection lives inside one specific Reverb process. The process holds it in memory, tracks its channel subscriptions, and answers its ping frames.&lt;/p&gt;

&lt;p&gt;When your Laravel app broadcasts an event, say &lt;code&gt;OrderShipped&lt;/code&gt;, it doesn't talk to the browser directly. It sends an HTTP request to Reverb's Pusher-compatible API, and Reverb then pushes the payload down every open connection subscribed to that channel. With a single Reverb process, this is beautifully simple. One process knows about every connection, so every subscriber gets the message. No coordination required, no backplane, no extra config.&lt;/p&gt;

&lt;p&gt;The moment you run two Reverb processes, that guarantee breaks. Picture process A on port 8080 and process B on port 8081, with Nginx load balancing between them. Alice's browser connects to process A. Bob's browser connects to process B. Your app broadcasts &lt;code&gt;OrderShipped&lt;/code&gt;, and the HTTP request happens to land on process A. Alice gets the event instantly. Bob gets nothing, because process B never heard about it. His connection is sitting in a different process's memory, invisible to A.&lt;/p&gt;

&lt;p&gt;This is the fan-out problem, and it's why Reverb has a scaling mode at all. When you set &lt;code&gt;REVERB_SCALING_ENABLED=true&lt;/code&gt;, each Reverb process stops assuming it knows about every subscriber. Instead, every broadcast is also published to a shared backplane, and every process subscribes to that backplane. Process A receives the event, delivers it to its local connections, and publishes it to the bus. Process B picks it up from the bus and delivers it to Bob. Same story across multiple servers: the backplane is what lets a Reverb process on server one reach a browser connected to server two.&lt;/p&gt;

&lt;p&gt;Historically, that backplane had exactly one implementation: Redis pub/sub. It's an excellent fit technically, since Redis pushes messages to subscribers with sub-millisecond latency and handles enormous throughput. But it made Redis a hard dependency the moment you needed more than one process. Even a modest two-process setup for redundancy dragged a new service into your stack. That's the assumption Laravel 13 finally relaxed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does the Laravel 13 Database Driver Actually Change?
&lt;/h2&gt;

&lt;p&gt;The change itself is easy to describe: Reverb's scaling layer is now driver-based, and &lt;code&gt;database&lt;/code&gt; joins &lt;code&gt;redis&lt;/code&gt; as a first-party option (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;). Instead of publishing inter-process messages through Redis pub/sub, each Reverb process writes broadcast payloads to a table in your existing database and polls that table on a short interval for messages published by its siblings. Each process keeps a cursor of the last message it applied, delivers new rows to its local connections, and old rows get pruned automatically so the table stays small.&lt;/p&gt;

&lt;p&gt;Mechanically it resembles how Laravel's database queue driver relates to the Redis queue driver. Same contract, different transport, different performance envelope. The write path adds one insert per broadcast, and the read path adds a cheap indexed query per polling tick per process. For an app broadcasting dozens or even hundreds of events per minute, that load is a rounding error on any reasonably sized MySQL or PostgreSQL instance.&lt;/p&gt;

&lt;p&gt;What you gain is operational subtraction. One less service to install and patch. One less port to firewall. One less thing to monitor at 3 a.m., and one less line item on your smallest client's VPS. Your database is already backed up, already monitored, and already part of your mental model. The PHP Everyday upgrade guide calls this out specifically as a simplification aimed at smaller deployments that want multi-process Reverb without growing their stack (&lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;What you trade away is push semantics. Redis pub/sub delivers messages to subscribers the instant they're published. A polling driver, by definition, delivers them on the next tick. In practice the interval is a fraction of a second, so a chat message crossing processes arrives tens of milliseconds later than it would over Redis. Users won't notice on a notification bell or a dashboard widget. A multiplayer cursor overlay is a different story, and we'll get to that in the decision framework.&lt;/p&gt;

&lt;p&gt;One thing that doesn't change at all: your application code. Broadcasting events, channel authorization, Echo on the frontend, presence channels, whispers, all of it is identical. The scaling driver is invisible above the transport layer. If you're planning the jump to Laravel 13 to get this feature, our guide to &lt;a href="https://deploynix.io/blog/upgrading-to-laravel-13-in-production-with-zero-downtime" rel="noopener noreferrer"&gt;upgrading to Laravel 13 in production with zero downtime&lt;/a&gt; walks through the full process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Scaling Backplane Should You Choose?
&lt;/h2&gt;

&lt;p&gt;Here's the framework we use when customers ask. It comes down to three honest options, and the right answer depends on your connection count, your latency sensitivity, and how much infrastructure you want to own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: A Single Reverb Process, No Backplane at All
&lt;/h3&gt;

&lt;p&gt;This option gets skipped in most scaling discussions, which is a shame, because it's the correct choice for the majority of Laravel apps. A single Reverb process on a modern VPS comfortably handles thousands of concurrent connections. If your app serves a few hundred simultaneous users, one process has enormous headroom, and with no second process there's no fan-out problem to solve. Leave &lt;code&gt;REVERB_SCALING_ENABLED&lt;/code&gt; unset and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Zero coordination overhead, zero extra latency, zero additional services. The simplest possible thing that works, and simple things fail less.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Apps with up to a few thousand concurrent connections, internal tools, dashboards, SaaS products in their first years, and anyone who hasn't yet measured a reason to scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; One process is one failure domain. If it crashes, everyone disconnects until Supervisor restarts it (Echo reconnects automatically, more on that below). You're also capped by a single CPU core for WebSocket work, so this stops being viable at genuinely large connection counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: The Database Driver
&lt;/h3&gt;

&lt;p&gt;This is the new middle path. You run two or more Reverb processes, on one server or several, and coordinate them through the database you already operate. It's the option Laravel 13 built for teams that outgrew a single process but never wanted the Redis conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Horizontal scaling and process redundancy with no new services. Uses infrastructure you already back up, secure, and monitor. Trivial to set up: enable scaling, set the driver, run the migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Moderate scale, roughly the range where you want two to a handful of Reverb processes for redundancy or multi-server topologies, with broadcast volumes in the hundreds of messages per minute. Also ideal for agencies running many small client apps where every extra service multiplies across the fleet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Cross-process delivery latency is bounded by the polling interval, so it's tens of milliseconds slower than pub/sub. Every broadcast adds database writes, and every process adds polling reads, so very high message volumes will make your database work for a living. High-frequency events like live cursors or typing indicators across processes will feel the lag.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: Redis or Valkey Pub/Sub
&lt;/h3&gt;

&lt;p&gt;The original backplane, and still the performance king. Messages move between processes with sub-millisecond latency, and a modest Redis or Valkey instance shrugs off tens of thousands of messages per second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Lowest possible cross-process latency, effectively unlimited fan-out throughput for anything a Laravel app will realistically produce, and battle-tested behavior at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; High fan-out workloads: busy chat products, live auctions, collaborative editing, real-time games, anything broadcasting high-frequency events to large audiences across multiple servers. Also the natural pick if Redis or Valkey is already in your stack for cache and queues, since the marginal cost is zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; It's another service with its own memory limits, persistence settings, and security posture. If it's only there for Reverb, you've added operational surface for one feature. On licensing and compatibility, Valkey is a drop-in replacement and our default recommendation; we compared the two in &lt;a href="https://deploynix.io/blog/valkey-vs-redis-for-laravel-caching-and-queues-what-you-need-to-know" rel="noopener noreferrer"&gt;Valkey vs Redis for Laravel caching and queues&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Three Options Side by Side
&lt;/h3&gt;

&lt;p&gt;Single process&lt;/p&gt;

&lt;p&gt;Database driver&lt;/p&gt;

&lt;p&gt;Redis / Valkey&lt;/p&gt;

&lt;p&gt;Extra services required&lt;/p&gt;

&lt;p&gt;None&lt;/p&gt;

&lt;p&gt;None (reuses your DB)&lt;/p&gt;

&lt;p&gt;Redis or Valkey instance&lt;/p&gt;

&lt;p&gt;Cross-process delivery latency&lt;/p&gt;

&lt;p&gt;Not applicable&lt;/p&gt;

&lt;p&gt;Polling interval (tens of ms)&lt;/p&gt;

&lt;p&gt;Sub-millisecond push&lt;/p&gt;

&lt;p&gt;Fan-out ceiling&lt;/p&gt;

&lt;p&gt;One process's capacity&lt;/p&gt;

&lt;p&gt;Moderate message volume&lt;/p&gt;

&lt;p&gt;Very high&lt;/p&gt;

&lt;p&gt;Horizontal scaling&lt;/p&gt;

&lt;p&gt;No&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Process redundancy&lt;/p&gt;

&lt;p&gt;No&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;Operational overhead&lt;/p&gt;

&lt;p&gt;Lowest&lt;/p&gt;

&lt;p&gt;Low&lt;/p&gt;

&lt;p&gt;Moderate&lt;/p&gt;

&lt;p&gt;Best fit&lt;/p&gt;

&lt;p&gt;Most apps, honestly&lt;/p&gt;

&lt;p&gt;Moderate scale, simple ops&lt;/p&gt;

&lt;p&gt;High-frequency, high fan-out&lt;/p&gt;

&lt;p&gt;A reasonable growth path: start with a single process, move to the database driver when you need redundancy or a second server, and reach for Valkey only when message frequency or audience size demands it. Each step is a config change, not a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Production Setup
&lt;/h2&gt;

&lt;p&gt;Whichever backplane you pick, the surrounding production setup is identical, and this is where most real-world Reverb problems actually live. We've debugged more broken WebSocket deployments caused by Nginx timeouts and file descriptor limits than by anything in Reverb itself. Here's the complete checklist we apply, assuming Ubuntu 24.04, PHP 8.5, and Nginx, the same baseline as our &lt;a href="https://deploynix.io/blog/setting-up-a-production-ready-laravel-stack-nginx-php-84-mysql-valkey-supervisor" rel="noopener noreferrer"&gt;production-ready Laravel stack guide&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment Configuration
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;.env&lt;/code&gt; defines the Reverb app credentials, the local bind address, and the public hostname the browser connects to. For a database-driver setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BROADCAST_CONNECTION=reverb

REVERB_APP_ID=482913
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret

REVERB_HOST=ws.example.com
REVERB_PORT=443
REVERB_SCHEME=https

REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080

REVERB_SCALING_ENABLED=true
REVERB_SCALING_DRIVER=database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Redis or Valkey, swap the last two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REVERB_SCALING_ENABLED=true
REVERB_SCALING_DRIVER=redis
REDIS_HOST=10.0.0.4
REDIS_PORT=6379
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details trip people up here. First, &lt;code&gt;REVERB_HOST&lt;/code&gt; and &lt;code&gt;REVERB_PORT&lt;/code&gt; are what the browser sees (your public TLS endpoint on 443), while &lt;code&gt;REVERB_SERVER_HOST&lt;/code&gt; and &lt;code&gt;REVERB_SERVER_PORT&lt;/code&gt; are where the Reverb process binds locally. Reverb itself should listen on &lt;code&gt;127.0.0.1&lt;/code&gt; and let Nginx terminate TLS in front of it. Second, when using the database driver, run your migrations after upgrading; the scaling table ships with the framework's Reverb migrations, and forgetting it produces confusing "works locally, silent in staging" behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supervisor: Keeping the Process Alive
&lt;/h3&gt;

&lt;p&gt;Reverb is a long-running process, and long-running processes need a supervisor. Here's the stanza we generate for a two-process setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:reverb]&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /home/deploynix/example.com/current/artisan reverb:start --host=127.0.0.1 --port=80%(process_num)02d --no-interaction&lt;/span&gt;
&lt;span class="py"&gt;process_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%(program_name)s_%(process_num)02d&lt;/span&gt;
&lt;span class="py"&gt;numprocs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deploynix&lt;/span&gt;
&lt;span class="py"&gt;stopasgroup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;killasgroup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;stopwaitsecs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;
&lt;span class="py"&gt;stdout_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/deploynix/.logs/reverb.log&lt;/span&gt;
&lt;span class="py"&gt;redirect_stderr&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;stopasgroup=true&lt;/code&gt; and &lt;code&gt;killasgroup=true&lt;/code&gt; lines matter more than they look. Without them, Supervisor signals only the parent process on restart, and any children keep running as orphans holding the port. That's the classic "address already in use" loop after a deploy. With &lt;code&gt;numprocs=2&lt;/code&gt;, Supervisor runs &lt;code&gt;reverb_00&lt;/code&gt; on port 8000 and &lt;code&gt;reverb_01&lt;/code&gt; on port 8001, and Nginx can balance across both. If you're running a single process, drop &lt;code&gt;numprocs&lt;/code&gt; and hardcode the port.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nginx: TLS Termination and the WebSocket Upgrade Block
&lt;/h3&gt;

&lt;p&gt;WebSocket connections start life as an HTTP request with an &lt;code&gt;Upgrade&lt;/code&gt; header, and Nginx will not forward that handshake unless you tell it to. This config handles TLS, the upgrade, and load balancing across two local Reverb processes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;reverb&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;ws.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/ws.example.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/ws.example.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://reverb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines do the WebSocket-specific work. &lt;code&gt;proxy_http_version 1.1&lt;/code&gt; is required because the upgrade mechanism doesn't exist in HTTP/1.0, which is Nginx's default for upstream connections. The &lt;code&gt;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; headers are hop-by-hop, so Nginx strips them unless you explicitly re-add them. The &lt;code&gt;map&lt;/code&gt; block is a small refinement worth keeping: it sends &lt;code&gt;Connection: upgrade&lt;/code&gt; for WebSocket handshakes but &lt;code&gt;Connection: close&lt;/code&gt; for plain HTTP requests to the same endpoint, which keeps Reverb's Pusher-protocol HTTP API working through the same server block.&lt;/p&gt;

&lt;h3&gt;
  
  
  The proxy_read_timeout Gotcha
&lt;/h3&gt;

&lt;p&gt;This one deserves its own section because it produces the most confusing symptom in WebSocket operations: connections that work perfectly, then die after exactly sixty seconds of quiet. Nginx's &lt;code&gt;proxy_read_timeout&lt;/code&gt; defaults to 60 seconds, and it measures the time between successive reads from the upstream. A WebSocket that hasn't sent anything for a minute looks, to Nginx, like a dead upstream, and Nginx closes it.&lt;/p&gt;

&lt;p&gt;Reverb's protocol-level ping frames usually keep traffic flowing inside that window, so many teams never hit this. But "usually" isn't a plan. A paused laptop, a delayed ping, or a saturated event loop is enough to cross the threshold, and then your users see mysterious reconnect churn that never reproduces in testing. Set &lt;code&gt;proxy_read_timeout&lt;/code&gt; (and &lt;code&gt;proxy_send_timeout&lt;/code&gt;) well above the ping interval; we use 300 seconds as a sane default. The client reconnects gracefully either way, but there's no reason to burn reconnects on a timeout you control.&lt;/p&gt;

&lt;h3&gt;
  
  
  File Descriptor Limits
&lt;/h3&gt;

&lt;p&gt;Every WebSocket connection is an open file descriptor, and Linux defaults assume you won't need many. A stock Ubuntu 24.04 process gets a soft limit of 1,024 descriptors, which means your "handles thousands of connections" Reverb process actually caps out around a thousand, then starts refusing connections with EMFILE errors.&lt;/p&gt;

&lt;p&gt;Raise the limit in three places so it survives every path a process can start from. In &lt;code&gt;/etc/security/limits.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deploynix soft nofile 65535
deploynix hard nofile 65535
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Supervisor main config, since Supervisor sets its own limit and children inherit it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[supervisord]&lt;/span&gt;
&lt;span class="py"&gt;minfds&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;65535&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And check the kernel ceiling with &lt;code&gt;sysctl fs.file-max&lt;/code&gt;, though on modern systems it's rarely the constraint. Remember Nginx holds a descriptor per proxied connection too, so bump &lt;code&gt;worker_rlimit_nofile&lt;/code&gt; and &lt;code&gt;worker_connections&lt;/code&gt; in &lt;code&gt;nginx.conf&lt;/code&gt; to match. We covered the full Reverb hardening pass, including these limits, in our earlier deep-dive on &lt;a href="https://deploynix.io/blog/laravel-reverb-deploynix-real-time-websockets-in-production" rel="noopener noreferrer"&gt;running Reverb in production&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Monitor Reverb in Production?
&lt;/h2&gt;

&lt;p&gt;The first number to watch is concurrent connections, because it's the number that predicts every other problem: file descriptor exhaustion, memory pressure, and the moment you need a second process. Laravel Pulse ships first-party Reverb cards that chart connection counts and message throughput per process, and for most teams that's the right starting point. It answers "how close are we to needing option two or three" with an actual graph instead of a guess.&lt;/p&gt;

&lt;p&gt;When you want ground truth from the server itself, the kernel already knows. Count established connections on Reverb's port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tn&lt;/span&gt; state established &lt;span class="s1"&gt;'( sport = :8000 )'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or count the process's open descriptors directly, which catches the EMFILE cliff before you fall off it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /proc/&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; reverb:start | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/fd | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alert on trends, not absolutes. A connection count that doubles week over week is your early warning to plan the backplane move while it's still a calm config change. If you're on the database driver, add two database-side checks: the size of the scaling table (pruning should keep it small, so sustained growth means something's wrong) and write latency on the broadcast path. If broadcasts start queueing behind slow inserts, your message volume has outgrown polling, and that's your signal that the Valkey conversation has finally earned its place on the agenda.&lt;/p&gt;

&lt;p&gt;Also watch memory per process. Reverb's event loop holds per-connection state, so memory grows roughly linearly with connections. A process that grows without connection growth is leaking, and &lt;code&gt;autorestart=true&lt;/code&gt; in Supervisor is your safety net, not your fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens During Deploys and Restarts?
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprises teams coming from stateless HTTP: a WebSocket server can't do a truly zero-downtime handoff of its connections. Each connection lives in a specific process's memory, and when that process exits, the connection goes with it. A deploy that restarts Reverb disconnects every client, full stop. The good news is that the ecosystem is built around this reality, and handled properly, users never notice.&lt;/p&gt;

&lt;p&gt;After a deploy, signal the running servers with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan reverb:restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This publishes a restart instruction over your scaling backplane (the database or Redis, whichever you've configured), and each Reverb process finishes its in-flight work, closes its connections cleanly, and exits. Supervisor sees the exit and immediately starts fresh processes running your new code. The &lt;code&gt;stopwaitsecs=15&lt;/code&gt; in the stanza above gives the graceful path room to complete before Supervisor escalates to SIGKILL.&lt;/p&gt;

&lt;p&gt;On the client side, Echo's underlying Pusher protocol client treats disconnection as normal weather. It reconnects automatically with exponential backoff, re-subscribes to every channel, re-authorizes private and presence channels, and rejoins presence member lists. The typical gap is one to a few seconds. Your job is to make that gap harmless: don't treat the WebSocket as a source of truth. Fetch current state on page load and on reconnect, and use broadcasts as invalidation hints rather than the only copy of the data. Events broadcast during the gap are not replayed, so any UI that must not miss updates should re-sync when Echo fires its reconnected state change.&lt;/p&gt;

&lt;p&gt;One more note for multi-process setups: &lt;code&gt;reverb:restart&lt;/code&gt; is another reason the backplane matters. With scaling enabled, one command gracefully cycles every process on every server. Without it, you're restarting processes one by one and hoping you didn't miss any.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverb on Deploynix: The Redis Question Is Already Answered
&lt;/h2&gt;

&lt;p&gt;We'll be direct about where our platform fits, because this post exists partly due to how often the "do I need Redis for Reverb?" question lands in our support inbox. On Deploynix, an app server ships with Valkey installed and configured out of the box, alongside Nginx, PHP-FPM, MySQL, and Supervisor. So the decision framework above gets simpler in practice: the Redis-class backplane is already there, already secured, already maintained. Whether you point Reverb's scaling driver at your database or at the local Valkey instance is purely a latency-versus-load decision, not an infrastructure project. That's true on every provider we support: DigitalOcean, Vultr, Linode, Hetzner, AWS, or any custom VPS.&lt;/p&gt;

&lt;p&gt;Running the process itself is a daemons job. In the server's Daemons UI, add &lt;code&gt;php artisan reverb:start --host=127.0.0.1 --port=8080&lt;/code&gt; with your app's directory, set the process count, and pick the stop signal. Under the hood we write a Supervisor program with the same &lt;code&gt;stopasgroup&lt;/code&gt; and &lt;code&gt;killasgroup&lt;/code&gt; safety rails shown earlier, and the daemon survives reboots and crashes without you touching a config file over SSH. Free SSL via certbot covers the &lt;code&gt;ws.&lt;/code&gt; subdomain, and server monitoring with alerts gives you the early warning on connection and memory trends we described above.&lt;/p&gt;

&lt;p&gt;When you do outgrow a single box, the pieces are already shaped for it. A dedicated cache server type runs Valkey with TLS enabled for exactly this backplane role, and the load balancer server type forwards WebSocket upgrade headers correctly out of the box, so multi-server Reverb doesn't require hand-editing proxy configs. If you're deciding how to split things up, our breakdown of &lt;a href="https://deploynix.io/blog/7-server-types-7-use-cases-picking-the-right-architecture-for-your-app" rel="noopener noreferrer"&gt;seven server types and when to use each&lt;/a&gt; maps these roles onto real application shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does the database scaling driver work with MySQL, PostgreSQL, and SQLite?
&lt;/h3&gt;

&lt;p&gt;It targets your default database connection, so MySQL and PostgreSQL are both fully supported in production. SQLite technically works and is handy for local multi-process testing, but its single-writer model makes it a poor backplane under real broadcast volume. Use whatever production database you already run; that's the entire point of the driver.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need Redis or Valkey for queues and cache if Reverb uses the database driver?
&lt;/h3&gt;

&lt;p&gt;No, the choices are independent. Reverb's scaling driver, your queue driver, and your cache driver are three separate settings. Plenty of Laravel 13 apps now run entirely Redis-free: database queues, database cache, and database-backed Reverb scaling. That said, if Valkey is already in your stack for queues, pointing Reverb at it costs nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many concurrent connections can one Reverb process handle?
&lt;/h3&gt;

&lt;p&gt;There's no fixed number, since payload size and message frequency matter as much as connection count. As a rough planning figure, a single process on a 2 vCPU server with proper file descriptor limits comfortably holds thousands of mostly-idle connections. Measure with Pulse under your real traffic before assuming you need a backplane at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will switching scaling drivers require any frontend changes?
&lt;/h3&gt;

&lt;p&gt;None. Echo, channel authorization, presence channels, and your broadcast events are all unchanged, because the scaling driver operates below the Pusher protocol layer. Moving from single-process to database to Valkey is an &lt;code&gt;.env&lt;/code&gt; change plus a process restart. Clients disconnect briefly during the restart and Echo reconnects and re-subscribes automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to messages broadcast while a client is reconnecting?
&lt;/h3&gt;

&lt;p&gt;They're gone, on every driver. Reverb delivers to currently connected clients and doesn't replay missed events. Design for it: fetch authoritative state on load and on Echo's reconnect event, and treat broadcasts as prompts to update, not as the system of record. This matters most during deploys, when every client reconnects at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;The database driver doesn't make Redis obsolete; it makes Redis optional, which is a more useful kind of progress. Single process for most apps, database driver when you need redundancy without new services, Valkey when fan-out and latency genuinely demand it. Each step up is a config change you can make the week you need it, not an architecture decision you have to get right on day one.&lt;/p&gt;

&lt;p&gt;If you want to put this into practice, the concrete next step is a staging run: provision an app server, add &lt;code&gt;reverb:start&lt;/code&gt; as a daemon, flip &lt;code&gt;REVERB_SCALING_DRIVER=database&lt;/code&gt; with two processes, and watch the connection graphs while you send real traffic through it. An afternoon of that will tell you more about your app's real-time profile than any decision table, ours included.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>reverb</category>
      <category>websockets</category>
      <category>laravel13</category>
    </item>
    <item>
      <title>Laravel 13's First-Party JSON:API Support: Standards-Compliant APIs Without Packages</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sun, 02 Aug 2026 21:56:36 +0000</pubDate>
      <link>https://dev.to/deploynix/laravel-13s-first-party-jsonapi-support-standards-compliant-apis-without-packages-4o8h</link>
      <guid>https://dev.to/deploynix/laravel-13s-first-party-jsonapi-support-standards-compliant-apis-without-packages-4o8h</guid>
      <description>&lt;p&gt;Every Laravel team that has shipped a standards-compliant API knows the tax: a third-party package like &lt;code&gt;laravel-json-api/laravel&lt;/code&gt;, its config files, a custom schema directory, and hundreds of lines of glue code holding the response format together. Laravel 13 moves that entire layer into the framework, behind resource classes that look almost exactly like the Eloquent API Resources every Laravel developer already knows. For teams running those packages today, the migration is mostly deletion — though as with any format swap consumed by mobile clients, you'll want contract tests proving the output matches before anything ships.&lt;/p&gt;

&lt;p&gt;That's the practical story behind one of Laravel 13's headline features. The release, which shipped on March 17, 2026, requires PHP 8.3 or higher and introduces no breaking changes to application code (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;). Tucked in alongside the usual quality-of-life improvements is first-party JSON:API support: new resource classes that handle response serialization, relationship inclusion, sparse fieldsets, links, and compliant response headers automatically (&lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;). Before this, standards-compliant APIs in Laravel meant third-party packages or a lot of hand-rolled convention documents that every new hire had to absorb.&lt;/p&gt;

&lt;p&gt;One caveat before the code: the samples below reflect the API surface as described in release coverage and our early usage. Exact class and method names can shift between minor releases, so treat them as the shape of the feature and confirm against &lt;a href="https://laravel.com/docs/13.x" rel="noopener noreferrer"&gt;the official docs&lt;/a&gt; for your installed version.&lt;/p&gt;

&lt;p&gt;We provision and deploy a lot of Laravel APIs at Deploynix, so we've spent real time with the new JSON:API layer since the release. This post covers what the specification buys you, how the new resource classes compare to classic Eloquent API Resources, how to handle includes and sparse fieldsets without wrecking your query count, and what changes at the server level when you take a compliant API to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does JSON:API Compliance Matter for a Laravel API?
&lt;/h2&gt;

&lt;p&gt;JSON:API is a specification for building HTTP APIs in JSON, maintained at &lt;a href="https://jsonapi.org" rel="noopener noreferrer"&gt;jsonapi.org&lt;/a&gt;. It defines the shape of every response your API returns: how resources are structured, how relationships are expressed, how errors are formatted, how clients request related data, and how pagination links are exposed. In other words, it answers all the questions your team currently answers in a Notion doc titled "API Conventions" that nobody has updated since 2024.&lt;/p&gt;

&lt;p&gt;That sounds bureaucratic until you've maintained an API consumed by more than one client. Every unspecified decision becomes a negotiation. Should errors be &lt;code&gt;{"error": "..."}&lt;/code&gt; or &lt;code&gt;{"errors": [...]}&lt;/code&gt;? Are timestamps ISO 8601 or Unix epochs? Does the mobile team get a slimmed-down payload, or do they download the full resource and throw most of it away over a cellular connection? Multiply those debates across five endpoints and three client teams and you've burned a sprint on formatting.&lt;/p&gt;

&lt;p&gt;Compliance pays off in three concrete ways. First, client tooling: because the document structure is standardized, generic JSON:API client libraries exist for TypeScript, Swift, Kotlin, and most other client-side ecosystems, so frontend teams deserialize your responses without writing bespoke mapping code. Second, standardized errors: the spec's &lt;code&gt;errors&lt;/code&gt; array with &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;detail&lt;/code&gt;, and &lt;code&gt;source.pointer&lt;/code&gt; members means validation failures render identically everywhere, and client error handling gets written once. Third, sparse fieldsets: clients ask for exactly the attributes they need, which trims payloads meaningfully for list endpoints on mobile networks.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a compliant response actually looks like
&lt;/h3&gt;

&lt;p&gt;Here's a minimal JSON:API document for a single article with its author included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"articles"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Zero-downtime deploys, explained"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"published_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-14T09:30:00+00:00"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"relationships"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"links"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"self"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.example.com/v1/articles/42"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"included"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rana Farouk"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every resource carries a &lt;code&gt;type&lt;/code&gt; and a string &lt;code&gt;id&lt;/code&gt;. Relationships are expressed as linkage objects rather than nested blobs, and related resources travel in a top-level &lt;code&gt;included&lt;/code&gt; array so each one appears exactly once, no matter how many resources reference it. Responses are served with the &lt;code&gt;application/vnd.api+json&lt;/code&gt; media type. None of this is hard to produce by hand. It's just tedious, and tedious formats drift. A framework-level implementation is what keeps them from drifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Ships in Laravel 13 for JSON:API?
&lt;/h2&gt;

&lt;p&gt;Laravel 13's contribution is a set of resource classes that sit next to the classic &lt;code&gt;JsonResource&lt;/code&gt; family. Extend the JSON:API base class instead of the classic one and the framework takes over the spec's mechanical obligations: it wraps your data in the correct document structure, resolves &lt;code&gt;include&lt;/code&gt; query parameters into the &lt;code&gt;included&lt;/code&gt; array, applies &lt;code&gt;fields[type]&lt;/code&gt; sparse fieldsets, generates &lt;code&gt;self&lt;/code&gt; and pagination links, and sets the &lt;code&gt;application/vnd.api+json&lt;/code&gt; content type on the way out (&lt;a href="https://www.phpeveryday.com/articles/laravel-13-2026-release-new-features-and-upgrade-guide/" rel="noopener noreferrer"&gt;PHP Everyday&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Because Laravel 13 has no application-code breaking changes (&lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel News&lt;/a&gt;), your existing classic resources keep working untouched. The JSON:API classes are additive. That matters for adoption: you can upgrade the framework first, then move endpoints to the new resources one route group at a time. If you haven't done the framework upgrade yet, we walked through the mechanics in &lt;a href="https://deploynix.io/blog/upgrading-to-laravel-13-in-production-with-zero-downtime" rel="noopener noreferrer"&gt;upgrading to Laravel 13 in production with zero downtime&lt;/a&gt;, and the short version is that this is the calmest major upgrade since Laravel 10.&lt;/p&gt;

&lt;h3&gt;
  
  
  The new resource classes at a glance
&lt;/h3&gt;

&lt;p&gt;A JSON:API resource looks like a classic resource that's been split into intent-revealing methods. Instead of one &lt;code&gt;toArray()&lt;/code&gt; returning everything, you declare attributes, relationships, and links separately, and the framework assembles the document:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>laravel</category>
      <category>api</category>
      <category>laravel13</category>
      <category>deploynix</category>
    </item>
    <item>
      <title>You Don't Need Kubernetes for Your Laravel App</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 16 Jul 2026 08:17:32 +0000</pubDate>
      <link>https://dev.to/deploynix/you-dont-need-kubernetes-for-your-laravel-app-598l</link>
      <guid>https://dev.to/deploynix/you-dont-need-kubernetes-for-your-laravel-app-598l</guid>
      <description>&lt;p&gt;In January 2026, another "we moved off Kubernetes" post hit the front page of Hacker News. The details were familiar: a &lt;a href="https://news.ycombinator.com/item?id=46576224" rel="noopener noreferrer"&gt;team of eight engineers spending roughly 60 hours per week&lt;/a&gt; maintaining their Kubernetes setup before finally replacing it with Docker Compose on plain servers. Sixty hours. That's one and a half full-time engineers doing nothing but keeping the orchestrator happy, for a product that could run on three machines.&lt;/p&gt;

&lt;p&gt;The comments followed the usual script. Half the thread said "you were doing it wrong." The other half said "we did the same thing and got our weekends back." Both halves are right, and that's the interesting part.&lt;/p&gt;

&lt;p&gt;This post makes a specific claim: if you're running a Laravel application, you almost certainly don't need Kubernetes, and the reasons are stronger for Laravel than for most stacks. I'll be fair about what Kubernetes actually solves, honest about what it costs, and specific about the boring architecture that covers 95% of Laravel apps in production. And I'll tell you when you genuinely do need k8s, because some readers do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern keeps repeating
&lt;/h2&gt;

&lt;p&gt;The "we left Kubernetes" essay is a genre now, and it sits inside a bigger trend. A Barclays CIO survey found that &lt;a href="https://www.hivelocity.net/blog/cloud-repatriation-why-workloads-are-moving-off-aws/" rel="noopener noreferrer"&gt;86% of CIOs plan to move some workloads back from the public cloud&lt;/a&gt;, and &lt;a href="https://deploynix.io/blog/laravel-cloud-repatriation-playbook-aws-to-vps" rel="noopener noreferrer"&gt;we've unpacked the whole repatriation trend separately&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Repatriation and de-Kubernetesing aren't the same thing, but they share a root cause. Teams adopted complexity designed for a scale they never reached, paid the tax for years, and eventually did the math.&lt;/p&gt;

&lt;p&gt;Here's what strikes me about the HN thread and every post like it: the teams involved aren't incompetent. They're usually good engineers who adopted Kubernetes for defensible reasons. Résumés, hiring pipelines, "we might need to scale," a consultant's recommendation, or a genuine belief that this is just how modern infrastructure works. The problem isn't the engineers. The problem is a mismatch between the tool and the workload.&lt;/p&gt;

&lt;p&gt;So let's define the workload Kubernetes was built for, and then look honestly at whether your Laravel app resembles it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Kubernetes actually solves
&lt;/h2&gt;

&lt;p&gt;It's easy to write a smug takedown of Kubernetes. It's more useful to be precise about what it's genuinely good at, because that precision is exactly what tells you whether you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bin-packing many heterogeneous services.&lt;/strong&gt; If you run 40 different services with wildly different resource profiles, some CPU-hungry, some memory-hungry, some spiky, Kubernetes will pack them onto a fleet of nodes far more efficiently than a human assigning services to servers in a spreadsheet. At that scale, the scheduler pays for itself in hardware savings alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Org-scale multi-team platforms.&lt;/strong&gt; When 15 teams each ship their own services, you need namespaces, resource quotas, RBAC, admission controllers, and a standard deploy interface so teams don't step on each other. Kubernetes is the industry's answer to "how do 200 engineers share a compute platform without a meeting for every deploy." That's a real problem, and k8s solves it well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;True elastic scaling.&lt;/strong&gt; If your traffic genuinely swings 10x within minutes, ticketing on-sales, viral consumer apps, ad-driven spikes, then horizontal pod autoscaling plus cluster autoscaling can absorb it without a human touching anything. Provisioning a new VPS by hand doesn't compete with that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-healing across nodes.&lt;/strong&gt; A node dies at 3 a.m., and the workloads on it get rescheduled elsewhere before your monitoring even pages you. If you run enough machines that node failure is a weekly event rather than a yearly one, this matters a lot.&lt;/p&gt;

&lt;p&gt;These are real capabilities. Kubernetes won the orchestration war because it does these four things better than anything else. Now, the uncomfortable question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does your Laravel app have any of these problems?
&lt;/h2&gt;

&lt;p&gt;Walk through the list again with a typical Laravel application in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heterogeneous services to bin-pack?&lt;/strong&gt; No. You have one codebase. Your "services" are the same application in different modes: PHP-FPM serving web requests, Horizon running queue workers, the scheduler firing cron entries, maybe Reverb for WebSockets. They share the same code, the same vendor directory, the same deploy artifact. There's nothing to bin-pack. A scheduler optimizing placement across a heterogeneous fleet has no work to do when the fleet is three identical app servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-team platform needs?&lt;/strong&gt; Almost certainly not. Most Laravel teams are 1 to 10 developers shipping one application. You don't need namespaces and RBAC to stop teams colliding when there's one team. The coordination problem Kubernetes solves at the org level simply doesn't exist for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elastic 10x traffic swings?&lt;/strong&gt; Be honest about your traffic graph. Most Laravel apps, SaaS products, internal tools, e-commerce, client platforms, have daily and weekly rhythms you can predict a month out. Your Tuesday looks like last Tuesday. When growth comes, it comes over weeks, which is plenty of time to resize a server or add one. In our experience, teams that think they need autoscaling usually need a correctly sized server and a queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node failure at fleet scale?&lt;/strong&gt; With 2 or 3 servers, hardware failure is a rare event, and a load balancer health check plus a spare app server handles it. You don't need a control plane rescheduling pods across a cluster; you need the LB to stop sending traffic to a dead box, which Nginx and every cloud load balancer has done since before Kubernetes existed.&lt;/p&gt;

&lt;p&gt;There's a deeper point here about Laravel specifically. The framework's entire production story was designed for boring servers. PHP-FPM's process model is its own worker pool manager. &lt;a href="https://deploynix.io/blog/running-laravel-horizon-on-deploynix" rel="noopener noreferrer"&gt;Horizon&lt;/a&gt; supervises, balances, and auto-scales queue workers within a machine, with a dashboard, out of the box. The scheduler collapses all your cron jobs into a single crontab entry. Octane exists for when you want to squeeze more requests out of the same hardware. Laravel already ships the 20% of orchestration you actually need, at the application layer, where you can debug it with &lt;code&gt;dd()&lt;/code&gt; instead of &lt;code&gt;kubectl describe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Kubernetes solves problems Laravel apps don't have, using abstractions Laravel already provides simpler versions of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Kubernetes costs you
&lt;/h2&gt;

&lt;p&gt;The costs are the half of the equation that adoption decisions consistently underweight. They come in four flavors.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stack tax
&lt;/h3&gt;

&lt;p&gt;Kubernetes is never just Kubernetes. To run a production Laravel app on it you'll assemble, at minimum: Helm charts or Kustomize overlays for templating, an ingress controller, cert-manager for TLS, an image registry and build pipeline, a secrets story (sealed-secrets, external-secrets, or a vault), and an observability stack because your logs are now scattered across ephemeral pods. Each piece has versions, upgrade cycles, breaking changes, and its own GitHub issues page you'll come to know well.&lt;/p&gt;

&lt;p&gt;Compare the artifact counts. A Laravel deploy on a plain server is an Nginx vhost, a Supervisor config, and a crontab line. The same app on Kubernetes means a deployment, service, ingress, HPA, ConfigMap, and secret manifest per component, plus the chart plumbing that templates them. Every one of those files is code you maintain and can misconfigure.&lt;/p&gt;

&lt;h3&gt;
  
  
  The money
&lt;/h3&gt;

&lt;p&gt;Managed control planes bill you for the privilege of the abstraction, and the worker nodes still cost the same as the VPSes you'd have used anyway. Meanwhile the boring option keeps getting cheaper: a &lt;a href="https://betterstack.com/community/guides/web-servers/digitalocean-vs-hetzner/" rel="noopener noreferrer"&gt;4 GB Hetzner CPX-class server runs about €7.99/month, versus $24/month for the equivalent DigitalOcean droplet&lt;/a&gt;, and Hetzner egress is around €1.19/TB against $10/TB at DO and roughly $90/TB at AWS. A three-server Laravel setup can cost less per month than the observability add-ons on a typical cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hiring bar
&lt;/h3&gt;

&lt;p&gt;Once Kubernetes is load-bearing, every infrastructure hire needs to know it, or you need to train them. You've raised the bar for a role that used to be "comfortable with Linux and Nginx." The HN team's &lt;a href="https://news.ycombinator.com/item?id=46576224" rel="noopener noreferrer"&gt;60 hours per week&lt;/a&gt; is the extreme case, but even a well-run small cluster quietly consumes a day or two of engineering time per week in upgrades, chart bumps, certificate mysteries, and CVE responses. That time comes directly out of product work.&lt;/p&gt;

&lt;h3&gt;
  
  
  The debugging tax
&lt;/h3&gt;

&lt;p&gt;This one hurts the most at 2 a.m. On a plain server, a misbehaving Laravel app is a &lt;code&gt;tail -f&lt;/code&gt; on the log, a &lt;code&gt;supervisorctl status&lt;/code&gt;, maybe an &lt;code&gt;strace&lt;/code&gt; if things get weird. On Kubernetes, the same investigation traverses ingress, service, endpoint, pod, container runtime, and overlay network before you reach your code. Each layer is a place the problem can hide and a place your mental model can be wrong. Abstraction layers are cheap on the whiteboard and expensive in an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring architecture that covers 95% of Laravel apps
&lt;/h2&gt;

&lt;p&gt;What does a typical Laravel app actually need? Three stages, and none of them involve YAML.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 1: one good VPS
&lt;/h3&gt;

&lt;p&gt;One server running Nginx, PHP-FPM, Horizon, MySQL, and Valkey. That's the whole diagram. A 4 vCPU / 8 GB machine handles more traffic than most developers expect; &lt;a href="https://deploynix.io/blog/how-much-traffic-can-a-5-server-handle-load-testing-laravel-on-deploynix" rel="noopener noreferrer"&gt;a well-tuned single server comfortably serves the workloads of the vast majority of production Laravel apps&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The moving parts are almost embarrassingly simple. Horizon runs under Supervisor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:horizon]&lt;/span&gt;
&lt;span class="py"&gt;process_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%(program_name)s&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /var/www/app/current/artisan horizon&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deploy&lt;/span&gt;
&lt;span class="py"&gt;stopwaitsecs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3600&lt;/span&gt;
&lt;span class="py"&gt;stdout_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/www/app/shared/storage/logs/horizon.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scheduler is one crontab line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/app/current &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; php artisan schedule:run &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's your orchestration layer. Supervisor restarts Horizon if it dies. Cron fires the scheduler. Nginx and PHP-FPM have been serving PHP reliably since before containers were a product category.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2: split the database
&lt;/h3&gt;

&lt;p&gt;When MySQL and PHP-FPM start competing for memory, move the database to its own server and point &lt;code&gt;DB_HOST&lt;/code&gt; at its private IP. Two servers, one firewall rule, done. This single move buys most apps years of headroom, and I've written about the decision criteria in detail in &lt;a href="https://deploynix.io/blog/building-a-multi-server-laravel-architecture-when-and-how-to-split-your-stack" rel="noopener noreferrer"&gt;Building a Multi-Server Laravel Architecture&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3: multiple app servers behind a load balancer
&lt;/h3&gt;

&lt;p&gt;When one app server isn't enough, add identical app servers behind a load balancer, keep sessions and cache in Valkey, move user uploads to S3-compatible storage, and dedicate one server to Horizon and the scheduler so jobs and cron entries run exactly once (or chain &lt;code&gt;-&amp;gt;onOneServer()&lt;/code&gt; onto scheduled tasks if the scheduler runs on every box). Three to five servers on this pattern will carry you a very long way; the full progression is mapped out in &lt;a href="https://deploynix.io/blog/scaling-laravel-from-1-to-100000-users" rel="noopener noreferrer"&gt;Scaling Laravel from 1 to 100,000 Users&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Notice what happened: you scaled from one server to a small fleet and never once needed a control plane, a service mesh, or a Helm chart. Each stage was an afternoon of work, and every piece is debuggable with tools you already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Kubernetes selling point, translated
&lt;/h2&gt;

&lt;p&gt;The strongest argument for Kubernetes is a list of features that sound essential. Here's the same list next to what the boring stack has been doing for years.&lt;/p&gt;

&lt;p&gt;Kubernetes gives you&lt;/p&gt;

&lt;p&gt;The boring equivalent&lt;/p&gt;

&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Rolling deployments&lt;/p&gt;

&lt;p&gt;Atomic symlink deploys&lt;/p&gt;

&lt;p&gt;New release built alongside the old one, symlink swap, instant rollback. See &lt;a href="https://deploynix.io/blog/the-anatomy-of-a-zero-downtime-deploy" rel="noopener noreferrer"&gt;The Anatomy of a Zero-Downtime Deploy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Liveness probes + pod restarts&lt;/p&gt;

&lt;p&gt;Supervisor/systemd &lt;code&gt;autorestart&lt;/code&gt; + monitoring alerts&lt;/p&gt;

&lt;p&gt;Supervisor restarts a dead Horizon in seconds; alerts tell you it happened&lt;/p&gt;

&lt;p&gt;Secrets objects&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; outside the web root, plus Laravel's encrypted env files&lt;/p&gt;

&lt;p&gt;Simpler threat model, and no etcd to secure&lt;/p&gt;

&lt;p&gt;Horizontal pod autoscaling&lt;/p&gt;

&lt;p&gt;Add a VPS behind the load balancer&lt;/p&gt;

&lt;p&gt;Takes minutes with predictable traffic; you'll see growth coming weeks out&lt;/p&gt;

&lt;p&gt;Service discovery + DNS&lt;/p&gt;

&lt;p&gt;Private IPs in your &lt;code&gt;.env&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You have three servers. You know their addresses&lt;/p&gt;

&lt;p&gt;Ingress + cert-manager&lt;/p&gt;

&lt;p&gt;Nginx + Let's Encrypt&lt;/p&gt;

&lt;p&gt;One vhost file and a certbot renewal timer&lt;/p&gt;

&lt;p&gt;ConfigMaps&lt;/p&gt;

&lt;p&gt;Config files, deployed with your code&lt;/p&gt;

&lt;p&gt;They're already versioned in Git&lt;/p&gt;

&lt;p&gt;The pattern in every row is the same: Kubernetes generalizes a problem to N services across M nodes, and pays for that generality in complexity. When N is 1 and M is 3, the specific solution wins on every axis that matters, including reliability, because there's so much less to break.&lt;/p&gt;

&lt;p&gt;Rolling deploys deserve one more sentence, since they're the feature people cite most. The releases-plus-symlink pattern gives you zero-downtime deploys and one-command rollbacks with &lt;code&gt;ln -sfn&lt;/code&gt;. It's not a lesser version of what Kubernetes does. For a single application, it's the same guarantee with two orders of magnitude less machinery.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you genuinely do need Kubernetes
&lt;/h2&gt;

&lt;p&gt;If everything above described your situation, you can skip this section. But some readers are shaking their heads, and some of them are right to. Kubernetes earns its keep when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You run dozens of genuinely distinct services. Not one Laravel app in three modes, but 30+ separately deployed services in multiple languages with different resource profiles. Now bin-packing and a uniform deploy interface pay real dividends.&lt;/li&gt;
&lt;li&gt;You have a platform engineering team. If multiple product teams ship independently and someone's full-time job is the internal platform, Kubernetes is a reasonable substrate. The 60 hours per week stops being overhead and becomes the job description.&lt;/li&gt;
&lt;li&gt;Your traffic is truly unpredictable at 10x scale. If a tweet or a TV spot can multiply your load in minutes and you can't eat the cost of permanent over-provisioning, cluster autoscaling solves a problem the boring stack handles awkwardly.&lt;/li&gt;
&lt;li&gt;You already have deep k8s expertise in-house. Familiarity changes the math. A team that has run clusters for years pays a fraction of the learning-curve tax, and consistency with existing infrastructure has value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If two or more of those apply, use Kubernetes with a clear conscience. The point of this essay isn't that k8s is bad. It's that the trigger for adopting it should be one of these conditions actually existing, not the possibility that one might exist someday. You can adopt Kubernetes in the quarter you need it. Adopting it three years early means paying the tax the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The middle ground
&lt;/h2&gt;

&lt;p&gt;Between raw Kubernetes and hand-managed servers there's a spectrum, and it's worth knowing where the sane points are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Compose on a VPS.&lt;/strong&gt; This is where the HN team landed, and it's a reasonable spot: you keep containerized builds and a declarative service definition, and drop the control plane entirely. The trade-off is that you're now managing Docker networking, image builds, and volume mounts for a stack (Nginx, FPM, MySQL) that installs perfectly well as native packages. For Laravel specifically, containers solve a dependency-isolation problem that PHP mostly doesn't have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed platforms.&lt;/strong&gt; Laravel Cloud (and, historically, Vapor) moves you up an abstraction level instead of down. You trade infrastructure control for zero maintenance, at a price that scales with usage. A fair option if usage-based billing fits your revenue model and you're comfortable not owning the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boring stack, automated.&lt;/strong&gt; This is the position Deploynix occupies, so weigh my bias accordingly. The architecture in this post, provisioned Nginx and PHP-FPM, Horizon under Supervisor, atomic symlink deploys with one-click rollback, Let's Encrypt, MySQL with automated backups, and server monitoring with alerts, is exactly what Deploynix sets up on a VPS from Hetzner, DigitalOcean, Vultr, Linode, AWS, or any server you can SSH into. You get the operational conveniences people adopt Kubernetes for (repeatable provisioning, safe deploys, restarts, alerts) without the cluster underneath. Flat pricing from free to $39/month, and no control plane to babysit.&lt;/p&gt;

&lt;p&gt;The common thread across all three: each one is somebody deciding which slice of Kubernetes's value they actually need and buying only that slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;The recurring "we left Kubernetes" post isn't a fad or a skill issue. It's teams discovering, one at a time, that they adopted a tool built for Google-shaped problems to run a workload shaped nothing like Google. A &lt;a href="https://news.ycombinator.com/item?id=46576224" rel="noopener noreferrer"&gt;team of eight spending 60 hours a week on cluster maintenance&lt;/a&gt; is an extreme data point, but the direction of the error is common: complexity adopted years before, or instead of, the problem it solves.&lt;/p&gt;

&lt;p&gt;Laravel makes the decision easier than most stacks. FPM manages your web workers. Horizon manages your queue workers. The scheduler manages your cron. Symlinks give you zero-downtime deploys. The framework already contains the orchestration a single application needs, which means Kubernetes has remarkably little left to offer until you're operating at genuine multi-service, multi-team scale.&lt;/p&gt;

&lt;p&gt;So start boring: one good VPS. Split the database when memory pressure says so. Add app servers behind a load balancer when traffic says so. Reach for Kubernetes when, and only when, you can point at one of the four conditions above and say "that's us, today." Your future self, awake at 2 a.m. reading a plain log file instead of spelunking through pod events, will thank you.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>laravel</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Deploying AI-Powered Laravel Apps: Queues, Streaming, Timeouts</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 16 Jul 2026 08:11:53 +0000</pubDate>
      <link>https://dev.to/deploynix/deploying-ai-powered-laravel-apps-queues-streaming-timeouts-5dck</link>
      <guid>https://dev.to/deploynix/deploying-ai-powered-laravel-apps-queues-streaming-timeouts-5dck</guid>
      <description>&lt;p&gt;Somewhere in the Laravel app you're running right now, there's a good chance an HTTP call goes out to OpenAI, Anthropic, or a local model. A chat feature, a summarizer, an agent that triages support tickets. &lt;a href="https://laravel-news.com/laravel-13" rel="noopener noreferrer"&gt;Laravel 13 shipped in March 2026&lt;/a&gt; with &lt;a href="https://laravel.com/docs/13.x/releases" rel="noopener noreferrer"&gt;first-party AI primitives&lt;/a&gt; and the framework now literally brands itself as being for &lt;a href="https://laravel.com" rel="noopener noreferrer"&gt;"Artisans and agents"&lt;/a&gt;. The application layer has never been easier.&lt;/p&gt;

&lt;p&gt;The server layer is another story. An LLM call breaks almost every assumption your default server config makes. Requests that last 30 to 120 seconds instead of 200 milliseconds. Responses that arrive token by token instead of all at once. Failures that are retry-able but cost real money every time you retry them. Nobody's hosting docs cover this, so here's the guide we wish existed: the actual server-side ops of running LLM workloads on a Laravel VPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LLM Calls Break Your Server's Defaults
&lt;/h2&gt;

&lt;p&gt;Your stack was tuned for short requests. Every layer between the browser and the LLM API has a timeout or a buffer, and nearly all of them are wrong for AI workloads:&lt;/p&gt;

&lt;p&gt;Layer&lt;/p&gt;

&lt;p&gt;Default&lt;/p&gt;

&lt;p&gt;Why it breaks&lt;/p&gt;

&lt;p&gt;PHP &lt;code&gt;max_execution_time&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.php.net/manual/en/info.configuration.php#ini.max-execution-time" rel="noopener noreferrer"&gt;30 seconds&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A 45-second completion dies mid-request&lt;/p&gt;

&lt;p&gt;Nginx &lt;code&gt;fastcgi_read_timeout&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_read_timeout" rel="noopener noreferrer"&gt;60 seconds&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nginx returns a 504 while the model is still thinking&lt;/p&gt;

&lt;p&gt;Nginx FastCGI buffering&lt;/p&gt;

&lt;p&gt;On&lt;/p&gt;

&lt;p&gt;Streamed tokens sit in a buffer; the user sees nothing, then everything&lt;/p&gt;

&lt;p&gt;Laravel HTTP client&lt;/p&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/13.x/http-client" rel="noopener noreferrer"&gt;30 seconds&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Long completions throw &lt;code&gt;ConnectionException&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Queue &lt;code&gt;retry_after&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/13.x/queues#job-expiration" rel="noopener noreferrer"&gt;90 seconds&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A 2-minute job gets handed to a second worker and billed twice&lt;/p&gt;

&lt;p&gt;That last row is the expensive one, and we'll spend a whole section on it. But first, the rule that prevents most of these problems from mattering at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule #1: LLM Calls Belong in Queued Jobs
&lt;/h2&gt;

&lt;p&gt;Never make an LLM call inside a web request if you can possibly avoid it. A synchronous call ties up a PHP-FPM worker for the full duration of the completion. With the default &lt;code&gt;pm.max_children&lt;/code&gt; on a small VPS, a dozen users triggering AI features simultaneously can exhaust your entire FPM pool, and now your login page is timing out because your summarizer is slow.&lt;/p&gt;

&lt;p&gt;Queued jobs fix the architecture. The web request dispatches a job and returns in milliseconds. A dedicated worker makes the slow call. The result comes back to the user via polling, broadcasting, or a stream (more on that below).&lt;/p&gt;

&lt;p&gt;Here's a job skeleton with every LLM-specific concern handled:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>laravel</category>
      <category>ai</category>
      <category>llm</category>
      <category>queues</category>
    </item>
    <item>
      <title>Your Deploy Pipeline Is an Attack Surface</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 16 Jul 2026 08:02:47 +0000</pubDate>
      <link>https://dev.to/deploynix/your-deploy-pipeline-is-an-attack-surface-195o</link>
      <guid>https://dev.to/deploynix/your-deploy-pipeline-is-an-attack-surface-195o</guid>
      <description>&lt;p&gt;In May 2026, the Laravel ecosystem got its wake-up call. According to &lt;a href="https://thehackernews.com/2026/05/laravel-lang-php-packages-compromised.html" rel="noopener noreferrer"&gt;a report from The Hacker News&lt;/a&gt;, more than 700 versions of Laravel-Lang packages were compromised and republished carrying a credential stealer. The malicious code targeted exactly what you'd expect a professional operation to target: cloud provider keys, CI/CD tokens, and SSH keys. And it executed through the most mundane command in a PHP developer's day, a Composer install.&lt;/p&gt;

&lt;p&gt;Laravel-Lang isn't an obscure dependency. It's the de facto standard for localized validation messages, pulled into countless Laravel projects, often as a transitive dependency nobody consciously chose. If your pipeline installed a poisoned version, the malware didn't need to break into anything. Your own build ran it, with your own credentials sitting in the environment.&lt;/p&gt;

&lt;p&gt;This post is a hardening guide. We'll walk through what happened, why deploy pipelines are such a valuable target, and the concrete Composer, secrets, and access-control changes that shrink your exposure to the next one. Because there will be a next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happened With Laravel-Lang
&lt;/h2&gt;

&lt;p&gt;The chain was depressingly simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Someone with publish rights was the way in. The typical entry point in attacks like this is a compromised maintainer account: not the code, not Packagist itself, just one set of credentials belonging to someone who can publish.&lt;/li&gt;
&lt;li&gt;Poisoned versions were published. Over 700 package versions across the Laravel-Lang ecosystem were republished with malicious code embedded, per The Hacker News report.&lt;/li&gt;
&lt;li&gt;The code executed at install time. The payload ran through Composer's plugin and script auto-run behavior. No one had to call the malicious code. Installing the package was enough.&lt;/li&gt;
&lt;li&gt;Credentials were exfiltrated. The stealer hunted for cloud keys, CI/CD tokens, and SSH keys, the exact material a build environment has to hold in order to do its job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what's missing from that chain: any vulnerability in your application. Your Laravel code could be flawless, your server patched, your firewall tight. None of it matters, because the attack came in through the front door, wearing the badge of a package you already trusted.&lt;/p&gt;

&lt;p&gt;That's the uncomfortable lesson of supply-chain attacks. &lt;code&gt;composer install&lt;/code&gt; is remote code execution that you invited. Usually the code it fetches is what you wanted. The entire security model rests on "usually."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Deploy Pipelines Are the Perfect Target
&lt;/h2&gt;

&lt;p&gt;If you were writing a credential stealer, where would you want it to run? Not on a developer's laptop with a lock screen and half the secrets missing. You'd want the deploy pipeline, because the pipeline is where everything valuable converges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud provider keys. CI jobs that push to S3, invalidate CDN caches, or manage infrastructure carry API keys for AWS, DigitalOcean, Hetzner, and friends.&lt;/li&gt;
&lt;li&gt;SSH keys. Something has to connect to your production servers. That something holds a private key, and it's usually the pipeline.&lt;/li&gt;
&lt;li&gt;Git credentials. Deploy keys and access tokens that can read (and sometimes write) your source code.&lt;/li&gt;
&lt;li&gt;Database credentials. Migrations run during deploys, which means the pipeline environment can reach your database with real credentials.&lt;/li&gt;
&lt;li&gt;Third-party API keys. Stripe, Paddle, Mailgun, OpenAI. If your &lt;code&gt;.env&lt;/code&gt; is present at build time, it's all there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And on top of that concentration of secrets, the pipeline has one more property attackers love: it runs code automatically, on a schedule or on every push, with no human watching the output scroll by. A malicious &lt;code&gt;composer install&lt;/code&gt; on a developer machine might get noticed. The same install in CI at 2 a.m. gets a green checkmark.&lt;/p&gt;

&lt;p&gt;Your deploy pipeline is a machine that holds all your keys and executes third-party code without supervision. Treat it with the same suspicion you'd apply to a public-facing endpoint, because functionally, that's what it is. Packagist is the input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening Composer
&lt;/h2&gt;

&lt;p&gt;This is the core of the defense. Composer has grown real security controls over the past few major versions; most projects just never turn them on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turn Off Script Execution Where You Can
&lt;/h3&gt;

&lt;p&gt;The single highest-impact flag is &lt;code&gt;--no-scripts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-scripts&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt; &lt;span class="nt"&gt;--prefer-dist&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;--no-scripts&lt;/code&gt;, Composer skips all lifecycle scripts during the install. For a Laravel app that matters more than it sounds, because the default &lt;code&gt;composer.json&lt;/code&gt; wires artisan into the install process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"post-autoload-dump"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Illuminate&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Foundation&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;ComposerScripts::postAutoloadDump"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"@php artisan package:discover --ansi"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;package:discover&lt;/code&gt; call boots your Laravel application on the build machine, which loads and executes code from your installed packages. If a dependency is poisoned, this is one of the moments its code gets to run with your pipeline's environment in scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What breaks when you skip scripts:&lt;/strong&gt; package discovery doesn't run, so Laravel's package manifest goes stale. The fix is to run the step explicitly, as a separate, deliberate command after the install and after any audit checks have passed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-scripts&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--prefer-dist&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt;
composer audit &lt;span class="nt"&gt;--locked&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt;
php artisan package:discover &lt;span class="nt"&gt;--ansi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be honest with yourself about what this buys you. It doesn't make a malicious package safe; a poisoned service provider will still execute the moment your app boots. What it does is reorder the pipeline so that nothing from a fresh install executes before your checks have run, and it strips out the install-time execution paths that stealers rely on for the smash-and-grab. Defense in depth, not a force field.&lt;/p&gt;

&lt;p&gt;A few packages genuinely rely on their own plugin hooks to function (patch appliers, autoload tweakers, &lt;code&gt;php-http/discovery&lt;/code&gt;). Test your build with &lt;code&gt;--no-scripts&lt;/code&gt; in staging first, and document any step you have to re-add manually. On Deploynix, deployment hooks give you full control of the install command, so swapping the default for the hardened sequence above is a one-line edit to your deploy script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Allowlist Composer Plugins Explicitly
&lt;/h3&gt;

&lt;p&gt;Scripts come from your root &lt;code&gt;composer.json&lt;/code&gt;, but plugins come from your dependencies, and plugins execute during Composer's own runtime. Since Composer 2.2, plugin execution is gated behind the &lt;code&gt;allow-plugins&lt;/code&gt; config. In interactive mode Composer prompts you; in CI, an unconfigured plugin fails the build, which is exactly the behavior you want.&lt;/p&gt;

&lt;p&gt;Make the allowlist explicit in &lt;code&gt;composer.json&lt;/code&gt; rather than letting prompts accumulate answers over the years:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow-plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"pestphp/pest-plugin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"php-http/discovery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sort-packages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules for maintaining it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default to &lt;code&gt;false&lt;/code&gt;. Only flip a plugin to &lt;code&gt;true&lt;/code&gt; when you understand what it does and why the package can't work without it.&lt;/li&gt;
&lt;li&gt;Review the list in every PR that touches &lt;code&gt;composer.json&lt;/code&gt;. A new &lt;code&gt;true&lt;/code&gt; entry in &lt;code&gt;allow-plugins&lt;/code&gt; is someone granting install-time code execution to a third party. That deserves at least the scrutiny you'd give a new middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also pass the &lt;code&gt;--no-plugins&lt;/code&gt; flag in CI jobs that only need &lt;code&gt;composer audit&lt;/code&gt; or validation, so those jobs execute no plugin code at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install From the Lockfile, Never Update on a Server
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;composer.lock&lt;/code&gt; file is a security control, not just a convenience. It pins every package, including transitives, to an exact version with a hash. Commit it, always, even for internal projects.&lt;/p&gt;

&lt;p&gt;Then enforce one rule everywhere outside a developer machine: &lt;strong&gt;CI and servers run **&lt;code&gt;composer install&lt;/code&gt;&lt;/strong&gt;, never &lt;strong&gt;&lt;code&gt;composer update&lt;/code&gt;&lt;/strong&gt;.** An &lt;code&gt;update&lt;/code&gt; resolves versions at run time, which means a package poisoned five minutes ago can walk straight into production. An &lt;code&gt;install&lt;/code&gt; from a committed lockfile can only ever give you versions that a human put into a commit, versions that existed and were reviewed at a specific point in time.&lt;/p&gt;

&lt;p&gt;Add validation so drift fails loudly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer validate &lt;span class="nt"&gt;--strict&lt;/span&gt;
composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-scripts&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--prefer-dist&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;composer validate --strict&lt;/code&gt; fails the build if the lockfile is out of sync with &lt;code&gt;composer.json&lt;/code&gt;, which catches the classic "edited composer.json on the server" mistake before it becomes a habit. &lt;code&gt;--prefer-dist&lt;/code&gt; pulls packaged archives rather than cloning source repositories, which is faster and keeps &lt;code&gt;.git&lt;/code&gt; metadata out of your vendor directory.&lt;/p&gt;

&lt;p&gt;The Laravel-Lang incident is the argument for this discipline in one sentence: a project installing from a lockfile pinned before the window couldn't have pulled a poisoned version; a project running unpinned updates during it easily could.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run composer audit in Every Pipeline
&lt;/h3&gt;

&lt;p&gt;Composer ships a built-in audit command that checks your installed packages against published security advisories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer audit &lt;span class="nt"&gt;--locked&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--locked&lt;/code&gt; audits what's actually in your lockfile rather than what's currently installed, and &lt;code&gt;--no-dev&lt;/code&gt; skips dev dependencies that never reach production. The command exits non-zero when it finds an advisory, so wiring it into CI is a two-line job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;security-audit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;composer audit --locked --no-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be clear about what this catches: &lt;strong&gt;known&lt;/strong&gt; compromises. During the window between a malicious publish and the advisory going out, &lt;code&gt;composer audit&lt;/code&gt; is blind. That's not a reason to skip it. Advisories for incidents like Laravel-Lang land fast once discovered, and an audit step in the pipeline is the difference between finding out from your CI logs and finding out from your cloud provider's fraud team. Run it on every deploy and on a daily schedule against your main branch, so a quiet week doesn't mean a quiet audit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treat New Transitive Dependencies as Code Review
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;composer.lock&lt;/code&gt; diff in a pull request is a list of code you're about to execute in the most privileged environment you own. Review it like one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read the lock diff. A minor bump of a first-party package that drags in three brand-new transitive dependencies is worth thirty seconds of "what are these and who maintains them."&lt;/li&gt;
&lt;li&gt;Prefer tight constraints for risky positions. Anything that runs at install time or in your build tooling deserves a pinned or narrowly-ranged constraint, not &lt;code&gt;^&lt;/code&gt; across major functionality changes.&lt;/li&gt;
&lt;li&gt;Use Dependabot or Renovate, but keep a human in the loop. Automated update PRs are great; automated merges of dependency updates are how poisoned versions ship themselves. Renovate's &lt;code&gt;minimumReleaseAge&lt;/code&gt; setting is particularly useful here: requiring a release to be several days old before a PR is even opened means most compromised versions get caught and pulled by the ecosystem before your bot ever proposes them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is heavy process. It's five minutes per dependency PR, and it's the only point in the whole pipeline where a human actually looks at what's changing in &lt;code&gt;vendor/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets Hygiene on the Server
&lt;/h2&gt;

&lt;p&gt;Assume the stealer runs anyway. What does it get? That question should drive how you lay out secrets on the server, and the honest answer for most Laravel apps is "everything, because it's all in one &lt;code&gt;.env&lt;/code&gt; file readable by the same user that runs Composer."&lt;/p&gt;

&lt;p&gt;Practical steps, in order of impact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make every credential least-privilege. Your &lt;code&gt;DB_USERNAME&lt;/code&gt; should not be &lt;code&gt;root&lt;/code&gt;. It should be an application user with privileges on the application database and nothing else: no &lt;code&gt;GRANT&lt;/code&gt;, no &lt;code&gt;SUPER&lt;/code&gt;, no access to other schemas. A stolen app credential that can read one database is an incident; a stolen root credential is a company-ending event on a bad day.&lt;/li&gt;
&lt;li&gt;Scope third-party API tokens. Most providers support restricted tokens now. A Stripe restricted key, an S3 key limited to one bucket with no &lt;code&gt;s3:DeleteObject&lt;/code&gt;, a DigitalOcean token scoped to read-only where write isn't needed. Every scope you don't grant is blast radius you don't have.&lt;/li&gt;
&lt;li&gt;Keep production secrets out of build environments entirely. Your CI job that runs &lt;code&gt;composer install&lt;/code&gt; and &lt;code&gt;npm run build&lt;/code&gt; does not need &lt;code&gt;DB_PASSWORD&lt;/code&gt; or &lt;code&gt;STRIPE_SECRET&lt;/code&gt;. If your pipeline copies the full &lt;code&gt;.env&lt;/code&gt; into the build stage "because it was easier," the Laravel-Lang stealer just thanked you.&lt;/li&gt;
&lt;li&gt;Encrypt what's at rest. Laravel's &lt;code&gt;php artisan env:encrypt&lt;/code&gt; lets you keep an encrypted environment file in the repo and decrypt it only at release time on the target server. We covered the full setup in our guide to secrets management for Laravel, including key handling and rotation workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The theme is the same everywhere: a credential stealer can only steal what's present and can only use what the credential permits. You control both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoped Deploy Keys and Short-Lived Tokens
&lt;/h2&gt;

&lt;p&gt;Access credentials for the pipeline itself deserve the same least-privilege treatment as application secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo deploy keys should be read-only.&lt;/strong&gt; A deploy key exists so a server can clone your code. It does not need push access, and on GitHub, deploy keys are read-only unless you deliberately check the write box. Leave it unchecked. A stolen read-only key leaks your source; a stolen writable key lets an attacker commit a backdoor that your own pipeline will then deploy for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One SSH key per server, never shared.&lt;/strong&gt; When every server has its own keypair, revocation is surgical: one compromised box means one key removed from &lt;code&gt;authorized_keys&lt;/code&gt; and one deploy key deleted from the repo. When five servers share a key, revoking it is a coordinated outage. Per-server keys also make your audit logs meaningful, because you can tell which machine actually connected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short-lived CI tokens beat long-lived personal access tokens.&lt;/strong&gt; A PAT created in 2024 and pasted into CI secrets is a skeleton key with no expiry and, usually, the full permissions of the human who made it. Modern CI platforms can do better: GitHub Actions can authenticate to cloud providers through OIDC, minting a token that lives for the duration of one job and dies with it. A stealer that grabs a token with a fifteen-minute lifetime has a fifteen-minute problem. One that grabs your two-year-old PAT has your account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotate on a schedule, not just on incidents.&lt;/strong&gt; Quarterly rotation of deploy keys and CI tokens is cheap insurance, and more importantly, it forces you to keep the rotation procedure documented and working. The worst time to discover nobody remembers how to rotate the deploy key is during an actual incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reducing the Blast Radius
&lt;/h2&gt;

&lt;p&gt;Separation is the strategy that makes every other control matter more. Two environments with half your secrets each are strictly better than one environment with all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate CI credentials from production credentials.&lt;/strong&gt; The build stage and the release stage are different trust zones. Build needs: repo read access, package registries, maybe an artifact store. Release needs: SSH to servers, and that's about it. When those are distinct credentials in distinct jobs, a compromise during &lt;code&gt;composer install&lt;/code&gt; gets the build zone only. This maps naturally onto atomic deployment models, where the build happens in a fresh release directory and only a symlink swap touches production; we broke down that flow in &lt;a href="https://deploynix.io/blog/the-anatomy-of-a-zero-downtime-deploy" rel="noopener noreferrer"&gt;The Anatomy of a Zero-Downtime Deploy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Think about what each environment can reach.&lt;/strong&gt; From your CI runner, can you connect to the production database? If yes, why? Most teams' answer is "migrations," and the better pattern is running migrations from the server during release rather than from CI across the internet. Every network path you close is a path the stealer can't use either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch your egress.&lt;/strong&gt; Exfiltration needs an outbound connection. Build machines and app servers typically have completely unrestricted outbound internet, which means stolen credentials leave silently. Full egress allowlisting is real work and often impractical for a small team, but even coarse measures help: outbound firewall rules on database servers (which have no business calling the internet), and alerting on unusual outbound destinations from app servers. Server-level monitoring that shows you resource and traffic anomalies, like the monitoring built into Deploynix, won't name the attacker, but a build server suddenly talking to an unfamiliar host during install is exactly the anomaly worth a look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep staging honest.&lt;/strong&gt; Staging environments often accumulate production credentials out of convenience: the real Stripe key "to test something," the production S3 bucket "temporarily." A stealer doesn't care which environment it runs in. If staging holds production secrets, staging is production, minus the hardening.&lt;/p&gt;

&lt;h2&gt;
  
  
  If You Installed a Compromised Version
&lt;/h2&gt;

&lt;p&gt;Advisory lands, and your lockfile shows one of the poisoned versions. Here's the run book. Work top to bottom; the order matters because you must rotate the credentials that control other credentials first, or an attacker uses the parent to reissue the child while you're busy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Contain first.&lt;/strong&gt; Pin or remove the compromised package versions, redeploy from a clean lockfile, and freeze other deploys while you work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Rotate in dependency order:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tier 1: Accounts that mint credentials
  - Cloud provider root/IAM credentials and API tokens
  - Git hosting account credentials, 2FA recovery codes
  - CI/CD platform tokens and secrets store

Tier 2: Credentials those accounts issued
  - Deploy keys (all repos)
  - Server SSH keypairs
  - CI job tokens, webhook secrets

Tier 3: Application secrets (the whole .env)
  - Database passwords
  - APP_KEY (plan for re-encryption of encrypted data)
  - Mail, payment, storage, and third-party API keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rotate everything the compromised environment could read, not just what you can prove was taken. Stealers don't leave receipts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Audit **&lt;code&gt;authorized_keys&lt;/code&gt;&lt;/strong&gt; on every server.** Line by line, on each box the pipeline could reach. Any key you can't positively attribute to a person or a specific server gets removed. Check &lt;code&gt;~/.ssh&lt;/code&gt; for every user account, not just the deploy user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Review cloud audit logs.&lt;/strong&gt; Pull the trail for the exposure window: API calls from unfamiliar IPs, newly created IAM users or access keys, changed security groups, new instances in regions you don't use. Attackers create their own credentials fast precisely so your rotation doesn't lock them out; look for what was created, not just what was used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Check the Git side.&lt;/strong&gt; New deploy keys, new webhooks, new OAuth app grants, recent commits and tags you don't recognize, changed CI workflow files. A modified pipeline definition is persistence, and it survives credential rotation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Write it down.&lt;/strong&gt; Which versions, which environments, what was rotated, what the logs showed. If customer data was reachable, your disclosure obligations may kick in, and a timeline built the same day beats one reconstructed a month later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10-Point Pipeline Hardening Checklist
&lt;/h2&gt;

&lt;p&gt;Print this one, or paste it into the team wiki:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;composer.lock&lt;/code&gt; is committed, and every non-dev environment runs &lt;code&gt;composer install&lt;/code&gt;, never &lt;code&gt;composer update&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;CI installs use &lt;code&gt;--no-scripts&lt;/code&gt;, with &lt;code&gt;package:discover&lt;/code&gt; run explicitly after checks pass.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allow-plugins&lt;/code&gt; is an explicit allowlist in &lt;code&gt;composer.json&lt;/code&gt;, defaulting to &lt;code&gt;false&lt;/code&gt;, reviewed on every change.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;composer audit --locked&lt;/code&gt; runs in every pipeline and daily on the main branch.&lt;/li&gt;
&lt;li&gt;Dependency update PRs get human review of the lock diff; no auto-merge, with a minimum release age on your update bot.&lt;/li&gt;
&lt;li&gt;Build environments hold no production secrets. No &lt;code&gt;DB_PASSWORD&lt;/code&gt;, no payment keys, nothing production can't afford to leak.&lt;/li&gt;
&lt;li&gt;Every credential is least-privilege: non-root DB users, scoped API tokens, restricted cloud keys.&lt;/li&gt;
&lt;li&gt;Deploy keys are read-only and per-server SSH keys are unique, so revocation is surgical.&lt;/li&gt;
&lt;li&gt;CI-to-cloud auth uses short-lived tokens (OIDC where available) instead of long-lived PATs.&lt;/li&gt;
&lt;li&gt;A rotation run book exists and has been tested, covering the full tiered order above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ten items, none of which require new tooling or budget. Most teams can close the whole list in an afternoon plus one staging test cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Change This Week
&lt;/h2&gt;

&lt;p&gt;The Laravel-Lang attack didn't exploit Laravel, or PHP, or a CVE in anything. It exploited the ecosystem's default posture of executing whatever the registry serves, &lt;a href="https://thehackernews.com/2026/05/laravel-lang-php-packages-compromised.html" rel="noopener noreferrer"&gt;delivered through Composer's install-time auto-run per the reporting on the incident&lt;/a&gt;, via what is typically the entry point in attacks like this: a compromised maintainer account. That posture is yours to change, at least for your own pipeline.&lt;/p&gt;

&lt;p&gt;You won't get to zero risk. You will, with lockfile discipline, script and plugin controls, scoped credentials, and separated environments, get to a place where a poisoned package yields an attacker a read-only deploy key and a fifteen-minute token instead of the keys to your cloud account. That's the difference between a bad afternoon and a disclosure letter.&lt;/p&gt;

&lt;p&gt;If you're rebuilding your deploy flow anyway, our &lt;a href="https://deploynix.io/blog/a-laravel-developers-production-security-checklist-2026-edition" rel="noopener noreferrer"&gt;production security checklist for 2026&lt;/a&gt; covers the server-side half of this picture, and Deploynix gives you the scaffolding the pipeline half sits on: customizable deployment hooks for the hardened install sequence, atomic releases with one-click rollback when you need to back out a bad dependency fast, and server monitoring that surfaces the anomalies worth investigating. The supply chain is everyone's problem now. Your pipeline doesn't have to be the easy target in it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>composer</category>
      <category>supplychain</category>
      <category>deployment</category>
    </item>
    <item>
      <title>Cloud Repatriation for Laravel: From AWS to a $10 VPS</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Wed, 15 Jul 2026 19:26:00 +0000</pubDate>
      <link>https://dev.to/deploynix/cloud-repatriation-for-laravel-from-aws-to-a-10-vps-1744</link>
      <guid>https://dev.to/deploynix/cloud-repatriation-for-laravel-from-aws-to-a-10-vps-1744</guid>
      <description>&lt;p&gt;When 37signals announced they were leaving AWS back in 2023, a lot of people treated it as a stunt. DHH being DHH. Three years later, the receipts are in: &lt;a href="https://northflank.com/blog/cloud-repatriation" rel="noopener noreferrer"&gt;roughly $2 million saved per year, on track for about $10 million over five years&lt;/a&gt;, and the stunt has become a trend with a name. Cloud repatriation.&lt;/p&gt;

&lt;p&gt;This post is that trend translated for Laravel. Not the abstract "cloud vs on-prem" debate that enterprise architects have at conferences, but the specific question you're probably asking: my Laravel app runs on EC2 with RDS, ElastiCache, an ALB, and a NAT gateway, and the bill keeps creeping up. Would the same app run fine on one or two VPSes for a tenth of the cost? Usually, yes. Here's the evidence, the trade-offs, and the exact migration sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why repatriation stopped being contrarian
&lt;/h2&gt;

&lt;p&gt;The numbers moved first, then the sentiment followed. A Barclays CIO survey found that &lt;a href="https://www.hivelocity.net/blog/cloud-repatriation-why-workloads-are-moving-off-aws/" rel="noopener noreferrer"&gt;86% of CIOs plan to move at least some workloads back from public cloud, up from 43% in 2020&lt;/a&gt;. That's not a fringe position anymore. That's the majority.&lt;/p&gt;

&lt;p&gt;The case studies got harder to dismiss too. 37signals is the loud one, but &lt;a href="https://northflank.com/blog/cloud-repatriation" rel="noopener noreferrer"&gt;GEICO, an insurance company with genuinely serious infrastructure, cut per-core costs by roughly 50% by pulling workloads back in-house&lt;/a&gt;. When a regulated Fortune 500 insurer and a small software company reach the same conclusion from opposite ends of the market, the middle should pay attention.&lt;/p&gt;

&lt;p&gt;The developer mood shifted alongside the finance numbers. A &lt;a href="https://news.ycombinator.com/item?id=46576224" rel="noopener noreferrer"&gt;Hacker News thread from January 2026 titled "Kubernetes Was Overkill"&lt;/a&gt; hit the front page with a story that will sound familiar: a team of eight spending around 60 hours a week feeding a k8s cluster, who moved to Docker Compose on plain servers and got their week back. Laravel teams rarely run Kubernetes, but the underlying pattern is identical. Complexity you adopted for scale you don't have, billed monthly.&lt;/p&gt;

&lt;p&gt;None of this means AWS is bad. It means AWS is priced for workloads most Laravel apps don't resemble. Let's look at where the money actually goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a Laravel app on AWS actually cost?
&lt;/h2&gt;

&lt;p&gt;A "standard" AWS deployment for a production Laravel SaaS usually looks like this: one or two EC2 instances (or Fargate tasks) behind an Application Load Balancer, RDS MySQL with Multi-AZ enabled because the checkbox said "recommended," an ElastiCache Redis node for cache and queues, a NAT gateway so instances in private subnets can reach the internet, S3 for storage, and CloudWatch collecting logs.&lt;/p&gt;

&lt;p&gt;Every one of those line items bills separately, and several bill in ways people consistently forget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The ALB charges per hour just for existing, then again in "load balancer capacity units" based on traffic. It never sleeps, so it never stops billing.&lt;/li&gt;
&lt;li&gt;The NAT gateway is the classic gotcha. Hourly charge plus a per-gigabyte processing charge on traffic in both directions. Your Composer installs, API calls, and S3 uploads from private subnets all flow through it, and it quietly becomes one of the larger items on small bills.&lt;/li&gt;
&lt;li&gt;RDS Multi-AZ roughly doubles your database instance cost, because you're paying for a standby replica that does nothing except wait for a failover most teams never experience.&lt;/li&gt;
&lt;li&gt;CloudWatch charges for log ingestion and retention. A chatty Laravel app shipping every request log to CloudWatch can generate a surprising bill for the privilege of storing logs you'll never read.&lt;/li&gt;
&lt;li&gt;Egress deserves its own section, because it's the single most distorted price in cloud computing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The egress tax
&lt;/h3&gt;

&lt;p&gt;Data leaving AWS is billed at a rate that has no relationship to what bandwidth actually costs. Compare the overage pricing across providers, &lt;a href="https://betterstack.com/community/guides/web-servers/digitalocean-vs-hetzner/" rel="noopener noreferrer"&gt;per Better Stack's DigitalOcean vs Hetzner comparison&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;Provider&lt;/p&gt;

&lt;p&gt;Egress overage per TB&lt;/p&gt;

&lt;p&gt;Cost of 5 TB/month egress&lt;/p&gt;

&lt;p&gt;AWS&lt;/p&gt;

&lt;p&gt;~$90&lt;/p&gt;

&lt;p&gt;~$450&lt;/p&gt;

&lt;p&gt;DigitalOcean&lt;/p&gt;

&lt;p&gt;$10&lt;/p&gt;

&lt;p&gt;$50&lt;/p&gt;

&lt;p&gt;Hetzner&lt;/p&gt;

&lt;p&gt;~€1.19&lt;/p&gt;

&lt;p&gt;~€5.95&lt;/p&gt;

&lt;p&gt;That's not a typo. AWS charges roughly 75 times what Hetzner charges for the same gigabyte leaving the same kind of datacenter (treating euro and dollar as near parity). And both DigitalOcean and Hetzner bundle generous transfer allowances with each server before overage pricing even kicks in, so a typical Laravel SaaS on either provider pays nothing at all for egress.&lt;/p&gt;

&lt;p&gt;If your app serves file downloads, media, exports, or a busy API, egress alone can justify the migration before you've compared a single compute price.&lt;/p&gt;

&lt;h3&gt;
  
  
  The compute gap
&lt;/h3&gt;

&lt;p&gt;Compute pricing tells a similar story with smaller multiples. The same Better Stack comparison puts a &lt;a href="https://betterstack.com/community/guides/web-servers/digitalocean-vs-hetzner/" rel="noopener noreferrer"&gt;4 GB Hetzner cloud instance at around €7.99/month against $24/month for a 4 GB DigitalOcean droplet&lt;/a&gt;, and an on-demand AWS instance with comparable specs lands well above both once you add EBS storage and the surrounding services. I've broken down full stage-by-stage numbers in &lt;a href="https://deploynix.io/blog/the-real-cost-of-running-a-laravel-saas" rel="noopener noreferrer"&gt;the real cost of running a Laravel SaaS&lt;/a&gt; if you want the line-item version.&lt;/p&gt;

&lt;p&gt;The honest summary: for a modest production Laravel app, the AWS stack described above typically lands in the low-to-mid hundreds of dollars per month once egress, NAT, Multi-AZ, and CloudWatch are counted. The equivalent VPS stack lands at $20-40. Per month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you actually give up by leaving AWS?
&lt;/h2&gt;

&lt;p&gt;This is where repatriation posts usually get dishonest, so let's not. AWS sells real things. The question is whether your Laravel app uses them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed RDS failover.&lt;/strong&gt; This one's genuine. Multi-AZ RDS gives you automatic failover in about a minute with zero operational effort, and nothing on a $10 VPS replicates that exactly. But be honest about your current posture: a lot of teams pay the Multi-AZ premium while running a deploy process, DNS setup, or application architecture that can't survive a failover gracefully anyway. On a VPS, your equivalent is automated backups plus a tested restore procedure, which for most single-region SaaS apps means accepting that a catastrophic database server failure costs you some minutes of downtime and a restore, maybe once every few years. Many businesses will trade that for thousands of dollars a year. Some genuinely can't, and that's fine; see the "when not to repatriate" section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IAM.&lt;/strong&gt; Astonishingly powerful, and almost entirely unused by small teams. If your IAM setup is "one role with too many permissions that everything uses," you're not giving up much. On a VPS the equivalent surface is SSH keys, a firewall, and per-database credentials, which is a smaller and more comprehensible security model. Worth noting: the &lt;a href="https://thehackernews.com/2026/05/laravel-lang-php-packages-compromised.html" rel="noopener noreferrer"&gt;May 2026 Laravel-Lang supply-chain attack specifically targeted cloud keys and CI/CD tokens&lt;/a&gt; via compromised Composer packages, so a smaller credential footprint isn't just simpler, it's a smaller target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autoscaling.&lt;/strong&gt; The headline cloud feature, and the least used in practice. Most Laravel SaaS traffic is boringly predictable: a daily curve, a weekly curve, growth measured in months. Autoscaling solves a problem shaped like "10x traffic in ten minutes," and if that's not your traffic shape, you're paying elastic prices for static load. On a VPS you buy headroom instead, and vertical scaling (resize, reboot, minutes of work) covers each growth step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance checkboxes.&lt;/strong&gt; If enterprise procurement requires workloads inside AWS with specific attestations, that's a business constraint, not a technical one. No VPS math changes it.&lt;/p&gt;

&lt;p&gt;Notice what's not on the list: deployment tooling, SSL, monitoring, queue workers, cron. None of that is an AWS feature. It's all reproducible on any server, which is exactly the layer a provisioning platform handles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repatriation architecture
&lt;/h2&gt;

&lt;p&gt;Here's the shape that replaces the EC2 + RDS + ElastiCache + ALB stack for the overwhelming majority of Laravel apps:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: one beefy VPS.&lt;/strong&gt; Nginx, PHP-FPM, MySQL, Valkey, and Horizon on a single 8-16 GB Hetzner instance. This sounds like heresy to anyone trained on AWS reference architectures, but a single modern VPS has far more headroom than most people believe, and it removes an entire class of network latency and configuration between your app, database, and cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: app server + database server.&lt;/strong&gt; Same stack, split across two instances: the app server runs Nginx, PHP-FPM, Horizon, and Valkey; the database server runs MySQL alone with its own RAM budget. This buys you independent scaling, a database that survives app-server rebuilds, and cleaner resource isolation for maybe $15-25/month extra. It's my default recommendation for anything with paying customers.&lt;/p&gt;

&lt;p&gt;Either way, this is precisely the setup Deploynix provisions: pick Hetzner (or DigitalOcean, Vultr, Linode, or bring your own box over SSH), choose the server type, and it configures Nginx, PHP-FPM, MySQL with automated backups, firewall rules, Supervisor-managed Horizon workers, cron, and Let's Encrypt SSL. The &lt;a href="https://deploynix.io/blog/hetzner-deploynix-the-best-price-to-performance-combo" rel="noopener noreferrer"&gt;Hetzner + Deploynix combination&lt;/a&gt; is the specific pairing I'd reach for on cost grounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about S3?&lt;/strong&gt; Keep it, or don't. Object storage is the one AWS service where staying put is defensible: S3 itself is cheap, and Laravel's filesystem abstraction means the app doesn't care. But if your S3 egress is meaningful (users downloading files), Cloudflare R2 or Backblaze B2 speak the S3 API, so the migration is usually a new disk in &lt;code&gt;config/filesystems.php&lt;/code&gt;, a bucket sync, and &lt;a href="https://developers.cloudflare.com/r2/pricing/" rel="noopener noreferrer"&gt;R2 in particular charges nothing for egress&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'r2'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'R2_ACCESS_KEY_ID'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'secret'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'R2_SECRET_ACCESS_KEY'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'region'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'bucket'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'R2_BUCKET'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'endpoint'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'R2_ENDPOINT'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The migration playbook, step by step
&lt;/h2&gt;

&lt;p&gt;The whole trick to a low-stress repatriation is refusing to do a big-bang cutover. You run both stacks in parallel, prove the new one works, and move traffic last. Budget two to three weeks of calendar time, most of it waiting, not working.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Provision the target
&lt;/h3&gt;

&lt;p&gt;Spin up the VPS (or pair) and let your provisioning tool build the stack: Nginx, PHP matching your production version, MySQL, Valkey, Supervisor. On Deploynix this is the connect-provider-and-click part; either way, resist the urge to hand-configure. You want this server reproducible, because the ability to rebuild it in minutes is your replacement for a chunk of AWS's managed-ness.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Deploy the app in parallel
&lt;/h3&gt;

&lt;p&gt;Point a deployment at the new server from the same Git repository and get a full release running against a copy of production data. Port your environment config carefully; this is the step where you discover the twelve &lt;code&gt;.env&lt;/code&gt; values that only exist in the ECS task definition. Set up &lt;a href="https://deploynix.io/blog/the-anatomy-of-a-zero-downtime-deploy" rel="noopener noreferrer"&gt;zero-downtime releases with symlink swaps&lt;/a&gt; from day one so the new stack's deploy story is already better than the old one.&lt;/p&gt;

&lt;p&gt;Run the boring checklist: queues process, scheduled tasks fire, mail sends, uploads land in the right bucket, Horizon dashboard loads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Replicate the database
&lt;/h3&gt;

&lt;p&gt;For small databases, a dump-and-restore during a maintenance window is fine and you can skip the replication ceremony:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysqldump &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; &lt;span class="nt"&gt;--routines&lt;/span&gt; &lt;span class="nt"&gt;--triggers&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; your-rds-endpoint.rds.amazonaws.com &lt;span class="nt"&gt;-u&lt;/span&gt; admin &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  your_database | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; dump.sql.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anything where extended downtime hurts, set the VPS MySQL up as an external replica of RDS. Extend binlog retention on RDS first so the replica can catch up after the initial import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;mysql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rds_set_configuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'binlog retention hours'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;168&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then import a consistent snapshot on the VPS and start replication against the RDS endpoint. From that point the VPS database trails production by seconds, and your eventual cutover downtime shrinks to almost nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Shadow-test
&lt;/h3&gt;

&lt;p&gt;Point a staging hostname at the new server and use it for real. Run your test suite against it. Have the team do their daily work through it for a few days. If you have synthetic monitoring or smoke tests, aim them here. What you're hunting for is environmental drift: a missing PHP extension, a locale difference, an outbound IP that a third-party API hasn't allowlisted. Every one of these is cheap to find now and expensive to find at 2 a.m. post-cutover.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Drop the DNS TTL
&lt;/h3&gt;

&lt;p&gt;Two days before cutover, lower the TTL on your app's DNS records to 60 seconds. This is the least glamorous and most important step in the playbook. A 24-hour TTL turns "roll back the cutover" from a one-minute action into a day of split traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Cut over
&lt;/h3&gt;

&lt;p&gt;Pick a low-traffic window. One thing &lt;code&gt;php artisan down&lt;/code&gt; does not do: it doesn't stop Horizon workers or the scheduler, which will happily keep processing jobs and writing to RDS after you promote the VPS, a recipe for split-brain writes. Stop them explicitly on the old stack first (disable Supervisor's autorestart before terminating, or just stop the program) and comment out the scheduler's cron entry there. The sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the old stack&lt;/span&gt;
php artisan down &lt;span class="nt"&gt;--retry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;60
supervisorctl stop horizon   &lt;span class="c"&gt;# or disable autorestart, then: php artisan horizon:terminate&lt;/span&gt;
crontab &lt;span class="nt"&gt;-e&lt;/span&gt;                   &lt;span class="c"&gt;# comment out the schedule:run entry&lt;/span&gt;

&lt;span class="c"&gt;# On the VPS: confirm the replica has fully caught up before promoting&lt;/span&gt;
mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SHOW REPLICA STATUS&lt;/span&gt;&lt;span class="se"&gt;\G&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# Seconds_Behind_Source must read 0&lt;/span&gt;

&lt;span class="c"&gt;# On the VPS: stop replication, promote to primary&lt;/span&gt;
mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"STOP REPLICA; RESET REPLICA ALL;"&lt;/span&gt;

&lt;span class="c"&gt;# Point .env at the local database, then&lt;/span&gt;
php artisan config:cache
php artisan up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flip DNS to the new server's IP. With a 60-second TTL, traffic moves over within a couple of minutes. Watch the logs, watch Horizon, watch your error tracker. Total downtime with the replication approach: under five minutes, most of it caution rather than necessity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Keep AWS warm, then kill it properly
&lt;/h3&gt;

&lt;p&gt;Leave the old stack running but idle for two weeks. It's your rollback path, and it's also where you'll notice the stragglers: a webhook still pointed at the old ALB, a cron job that only runs monthly, an office IP allowlist. After two weeks of silence, decommission deliberately, because AWS keeps billing for things you forgot were separate: EBS volumes and snapshots, unattached Elastic IPs, the NAT gateway, the ALB itself. Terminating the EC2 instances alone does not stop the bleeding. Walk the bill line by line until it reads zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if you're on Vapor?
&lt;/h2&gt;

&lt;p&gt;The serverless pendulum is swinging back too, and I say that as someone who wanted it to work. Vapor's promise was Laravel without servers; the reality for many teams was cold starts, VPC-plus-NAT charges for database access, and a bill that scaled with success in unpredictable ways. I've written up the full accounting in &lt;a href="https://deploynix.io/blog/the-serverless-hype-vs-reality-for-laravel-apps" rel="noopener noreferrer"&gt;the serverless hype vs reality for Laravel apps&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're on Vapor today, you have two exits. The official one is Laravel Cloud: there's a &lt;a href="https://laravel.com/cloud/migrate-vapor-cloud" rel="noopener noreferrer"&gt;documented Vapor-to-Cloud migration path&lt;/a&gt;, and Cloud's June 2026 pricing update brought &lt;a href="https://laravel.com/index.php/blog/laravel-cloud-more-features-smarter-pricing" rel="noopener noreferrer"&gt;tiers from $5/month with scale-to-zero hibernation and spending caps&lt;/a&gt;. It's a genuinely reasonable landing spot if you want to stay fully managed and usage-priced.&lt;/p&gt;

&lt;p&gt;The other exit is the one this post describes: a VPS, where the pricing model is a flat number you can recite from memory. Vapor apps migrate to servers more easily than people expect, since the codebase is still just Laravel. Queues move from SQS back to Redis with a config change, and the things Vapor abstracted (workers, cron, TLS) are exactly what server platforms automate anyway. If cost predictability was your reason for leaving, usage-priced serverless to usage-priced managed cloud is a smaller move than it looks; the VPS is the option that actually changes the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't repatriate
&lt;/h2&gt;

&lt;p&gt;Repatriation is a tool, not a religion. Stay on AWS when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your traffic is genuinely elastic. If you routinely see 10x spikes on unpredictable schedules (ticketing, viral consumer apps, election-night dashboards), autoscaling is doing real work for you and a fixed-size VPS forces you to pay for peak capacity all month. That said, check your actual metrics before claiming this; almost everyone thinks their traffic is spikier than it is.&lt;/li&gt;
&lt;li&gt;Compliance is welded to AWS services. If your SOC 2 scope, HIPAA BAA, or enterprise contracts name specific AWS services, migrating means re-auditing. Sometimes that's worth it. It's rarely worth it this quarter.&lt;/li&gt;
&lt;li&gt;You're deeply coupled to AWS primitives. An app orchestrating Lambda fan-outs, Kinesis streams, and Textract jobs isn't a Laravel app on AWS, it's an AWS app with a Laravel frontend. Repatriate the parts that make sense, if any.&lt;/li&gt;
&lt;li&gt;Nobody on the team will own servers. A managed panel removes most of the ops burden, but "most" isn't "all." Someone still needs to care when a disk-usage alert fires. If your team has zero appetite for that, at any tooling level, a fully managed platform is the honest choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A worked 12-month example
&lt;/h2&gt;

&lt;p&gt;Take a modest SaaS: a few thousand users, one production app, a queue-heavy workload, 2 TB of monthly egress from file exports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AWS version&lt;/strong&gt; of this stack (two small instances behind an ALB, Multi-AZ RDS, an ElastiCache node, a NAT gateway, CloudWatch, and that 2 TB of egress at &lt;a href="https://betterstack.com/community/guides/web-servers/digitalocean-vs-hetzner/" rel="noopener noreferrer"&gt;~$90/TB&lt;/a&gt;) reliably lands in the mid hundreds of dollars per month. I won't pretend to a precise total, because AWS bills never are, but the egress line alone is around $180/month and it's rarely the biggest item. Call it several thousand dollars a year, trending up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The VPS version&lt;/strong&gt;, priced concretely:&lt;/p&gt;

&lt;p&gt;Item&lt;/p&gt;

&lt;p&gt;Monthly cost&lt;/p&gt;

&lt;p&gt;Hetzner app server, 4 vCPU / 8 GB (Nginx, FPM, Horizon, Valkey)&lt;/p&gt;

&lt;p&gt;~€7.99-16 (&lt;a href="https://betterstack.com/community/guides/web-servers/digitalocean-vs-hetzner/" rel="noopener noreferrer"&gt;source&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Hetzner database server, 8-16 GB MySQL&lt;/p&gt;

&lt;p&gt;~€8-16 (estimate, based on Hetzner's cited 4 GB pricing)&lt;/p&gt;

&lt;p&gt;Deploynix Starter plan&lt;/p&gt;

&lt;p&gt;$7 flat&lt;/p&gt;

&lt;p&gt;Egress (2 TB, within included allowance)&lt;/p&gt;

&lt;p&gt;$0&lt;/p&gt;

&lt;p&gt;Backups to S3-compatible storage&lt;/p&gt;

&lt;p&gt;pennies per month&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~$30-40/month&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's roughly $360-480 a year for the whole production footprint, management layer included, versus thousands on AWS for the same app serving the same users. The delta pays for a lot: a contractor security review, a year of every SaaS tool in your stack, or simply margin. And because both the servers and the Deploynix plan are flat-priced, the number in month twelve is the number in month one. No usage meter, no surprise NAT charges, no egress anxiety.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;The repatriation trend isn't developers getting nostalgic for servers. It's the market repricing a decade of "nobody got fired for choosing AWS" now that &lt;a href="https://www.hivelocity.net/blog/cloud-repatriation-why-workloads-are-moving-off-aws/" rel="noopener noreferrer"&gt;86% of CIOs&lt;/a&gt; have looked at the bill and &lt;a href="https://northflank.com/blog/cloud-repatriation" rel="noopener noreferrer"&gt;companies like 37signals and GEICO&lt;/a&gt; have published what leaving actually saves.&lt;/p&gt;

&lt;p&gt;For Laravel specifically, the trade is unusually favorable. The stack is simple (Nginx, PHP-FPM, MySQL, Redis-compatible cache, a queue worker), it runs beautifully on commodity VPSes, and the operational layer that used to require a sysadmin is now a $12/month platform subscription. The parallel-run playbook above takes the drama out of the move: provision, deploy, replicate, shadow-test, drop the TTL, cut over, keep AWS warm for two weeks.&lt;/p&gt;

&lt;p&gt;If you want to test the thesis cheaply, that's what free tiers are for. Provision a Hetzner box through Deploynix's Free plan, deploy a copy of your app next to your AWS stack, and compare the two with your own traffic. The numbers in this post are persuasive. Yours will be decisive.&lt;/p&gt;

</description>
      <category>cloudrepatriation</category>
      <category>aws</category>
      <category>vps</category>
      <category>costoptimization</category>
    </item>
    <item>
      <title>FrankenPHP in Production on a Plain VPS: The Real Setup</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Wed, 15 Jul 2026 19:19:55 +0000</pubDate>
      <link>https://dev.to/deploynix/frankenphp-in-production-on-a-plain-vps-the-real-setup-372p</link>
      <guid>https://dev.to/deploynix/frankenphp-in-production-on-a-plain-vps-the-real-setup-372p</guid>
      <description>&lt;p&gt;FrankenPHP quietly won the Laravel Octane server race. Swoole needs a compiled PHP extension that fights with your package manager. RoadRunner needs a separate binary and its own YAML config. FrankenPHP is one download, and since Octane added support for it as documented on &lt;a href="http://blog.laravel.com" rel="noopener noreferrer"&gt;blog.laravel.com&lt;/a&gt;, it's become the default answer when someone asks "which Octane server should I use?"&lt;/p&gt;

&lt;p&gt;We covered the conceptual trade-offs in &lt;a href="https://deploynix.io/blog/php-fpm-vs-laravel-octane-on-deploynix" rel="noopener noreferrer"&gt;PHP-FPM vs Laravel Octane on Deploynix&lt;/a&gt;. This post is the follow-up: the hands-on guide to actually running FrankenPHP on a normal Ubuntu VPS. Not a container platform, not a managed runtime. A plain server you SSH into, with systemd, Nginx, and a deploy script.&lt;/p&gt;

&lt;p&gt;We'll set it up properly, then spend serious time on the part most tutorials skip: the worker mode bugs that only show up in production, and the situations where you shouldn't use FrankenPHP at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What FrankenPHP Actually Is
&lt;/h2&gt;

&lt;p&gt;FrankenPHP is not "another PHP server" bolted together from parts. It's a &lt;a href="https://frankenphp.dev/docs/laravel/" rel="noopener noreferrer"&gt;Caddy module that embeds the PHP interpreter directly into the Caddy web server&lt;/a&gt;, compiled into a single Go binary. Download one file and you have a web server, TLS termination, HTTP/2 and HTTP/3, and PHP itself. No PHP-FPM pool, no separate &lt;code&gt;php&lt;/code&gt; package, no FastCGI socket between the web server and the runtime.&lt;/p&gt;

&lt;p&gt;That single-binary design is why it beat the alternatives on developer experience. There's nothing to compile and no extension to match against your PHP version, because the PHP version ships inside the binary.&lt;/p&gt;

&lt;p&gt;It runs in two distinct modes, and the difference matters more than anything else in this post:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classic mode&lt;/strong&gt; works like PHP has always worked. Each request boots your application from scratch, handles the request, and throws everything away. It's a drop-in replacement for Nginx + PHP-FPM with fewer moving parts. Same execution model, same safety guarantees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker mode&lt;/strong&gt; boots your Laravel application once, holds it in memory, and feeds it request after request. The framework bootstrap, container building, provider registration, config loading: all of it happens once per worker instead of once per request. This is the mode Octane uses, and it's where the performance wins come from. It's also where the bugs live.&lt;/p&gt;

&lt;p&gt;One more thing you get for free: because FrankenPHP &lt;em&gt;is&lt;/em&gt; Caddy, it can do &lt;a href="https://frankenphp.dev/docs/laravel/" rel="noopener noreferrer"&gt;automatic HTTPS&lt;/a&gt;, provisioning and renewing Let's Encrypt certificates on its own when it faces the internet directly. Whether you actually want it facing the internet directly is a real question we'll get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing FrankenPHP on Ubuntu
&lt;/h2&gt;

&lt;p&gt;You have two sensible paths on a plain VPS. Pick based on how the binary will be managed: standalone, or through Octane.&lt;/p&gt;

&lt;h3&gt;
  
  
  Path 1: The Static Binary
&lt;/h3&gt;

&lt;p&gt;For classic mode or manual setups, grab the static binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://frankenphp.dev/install.sh | sh
&lt;span class="nb"&gt;sudo mv &lt;/span&gt;frankenphp /usr/local/bin/
frankenphp version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole installation. One binary in &lt;code&gt;/usr/local/bin&lt;/code&gt;, no apt repositories, no &lt;code&gt;php8.4-fpm&lt;/code&gt; service, no extension packages. The static build bundles PHP and the common extensions Laravel needs. The rest of this guide follows the Octane path below, but the systemd and worker-mode guidance applies to both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Path 2: Through Laravel Octane
&lt;/h3&gt;

&lt;p&gt;If you're running Laravel (you probably are, if you're reading this blog), let Octane manage the binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/octane
php artisan octane:install &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;frankenphp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Octane downloads a FrankenPHP binary into your project and wires up the config. From there, starting the server locally is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan octane:start &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;frankenphp &lt;span class="nt"&gt;--host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We prefer the Octane path for Laravel apps for one practical reason: the binary version travels with the project, so your deploy pipeline and your production server can't drift apart. The &lt;a href="https://frankenphp.dev/docs/laravel/" rel="noopener noreferrer"&gt;FrankenPHP Laravel docs&lt;/a&gt; cover both approaches if you want the raw-Caddyfile route instead.&lt;/p&gt;

&lt;p&gt;Run it once in the foreground, hit the port with &lt;code&gt;curl&lt;/code&gt;, confirm you get your app back. Then never run it in the foreground again. Production processes belong to systemd.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Production systemd Unit That Actually Works
&lt;/h2&gt;

&lt;p&gt;Every FrankenPHP tutorial ends with &lt;code&gt;php artisan octane:start --server=frankenphp&lt;/code&gt; in a terminal, which dies the moment your SSH session drops. Here's a complete unit file for a real server. Save it as &lt;code&gt;/etc/systemd/system/octane.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Laravel Octane (FrankenPHP)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target mysql.service redis-server.service&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deployer&lt;/span&gt;
&lt;span class="py"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deployer&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/deployer/myapp/current&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/php artisan octane:start &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;--server=frankenphp &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;--host=127.0.0.1 &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;--port=8000 &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;--workers=4 &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;--max-requests=500&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;

&lt;span class="c"&gt;# Caddy (inside FrankenPHP) needs writable config/data dirs
&lt;/span&gt;&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;XDG_CONFIG_HOME=/home/deployer/.config&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;XDG_DATA_HOME=/home/deployer/.local/share&lt;/span&gt;

&lt;span class="c"&gt;# Resource guardrails
&lt;/span&gt;&lt;span class="py"&gt;LimitNOFILE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;65535&lt;/span&gt;
&lt;span class="py"&gt;MemoryHigh&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;768M&lt;/span&gt;
&lt;span class="py"&gt;MemoryMax&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1G&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; octane
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status octane
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few deliberate choices worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;User=deployer&lt;/code&gt;, not root. FrankenPHP binds to 8000 here, not 443, so it needs no privileges. If you later let it bind 443 directly, grant &lt;code&gt;CAP_NET_BIND_SERVICE&lt;/code&gt; via &lt;code&gt;AmbientCapabilities&lt;/code&gt; rather than running as root.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WorkingDirectory&lt;/code&gt; points at &lt;code&gt;current&lt;/code&gt;. If you use atomic symlink deploys (and you should, see The Anatomy of a Zero-Downtime Deploy), the unit points at the symlink. Remember that the running workers resolved that path at boot; more on that in the deploy section.&lt;/li&gt;
&lt;li&gt;The XDG variables are not optional. Caddy wants somewhere to write config and state. Without them, you'll chase a confusing "failed to create config directory" error on first start under systemd, because the service environment doesn't have the HOME-derived defaults your shell does.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MemoryHigh&lt;/code&gt;/&lt;code&gt;MemoryMax&lt;/code&gt; are your safety net against slow leaks. &lt;code&gt;MemoryHigh&lt;/code&gt; throttles, &lt;code&gt;MemoryMax&lt;/code&gt; kills and, combined with &lt;code&gt;Restart=always&lt;/code&gt;, gives you a self-healing worst case: a leaky worker set gets recycled instead of taking the whole VPS into swap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--workers=4&lt;/code&gt; is a starting point, not a recommendation. A reasonable default is one to two workers per CPU core for typical database-bound Laravel apps. Measure, then adjust.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Should Nginx Stay in Front, or Should Caddy Face the Internet?
&lt;/h2&gt;

&lt;p&gt;This is the first real architecture decision, and the answer depends on what's already on the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: FrankenPHP Behind Your Existing Nginx
&lt;/h3&gt;

&lt;p&gt;If your server already runs Nginx (every Deploynix-provisioned server does, since Nginx fronts each site by default), the path of least resistance is to keep it there. Nginx keeps owning ports 80 and 443, keeps its existing Let's Encrypt certificates, and proxies to Octane on localhost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;myapp.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/myapp.example.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/myapp.example.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Serve static assets directly, skip PHP entirely&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/home/deployer/myapp/current/public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(css|js|jpg|jpeg|png|gif|svg|woff2?)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;120s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the setup we run in practice. You keep everything you already trust about your Nginx config: rate limiting, static file serving, existing certificate automation, other sites on the same box. Octane becomes just another upstream. The &lt;code&gt;Upgrade&lt;/code&gt; headers matter if you're running Reverb or any WebSocket traffic through the same host.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: FrankenPHP Owns Port 443
&lt;/h3&gt;

&lt;p&gt;On a fresh single-purpose VPS with nothing else running, letting FrankenPHP face the internet directly is genuinely simpler. Caddy handles certificate issuance and renewal automatically, you delete Nginx from the stack entirely, and one process serves everything.&lt;/p&gt;

&lt;p&gt;When does each make sense?&lt;/p&gt;

&lt;p&gt;Situation&lt;/p&gt;

&lt;p&gt;Recommendation&lt;/p&gt;

&lt;p&gt;Existing server with Nginx and multiple sites&lt;/p&gt;

&lt;p&gt;Nginx in front, Octane on localhost&lt;/p&gt;

&lt;p&gt;Deploynix or similar provisioned server&lt;/p&gt;

&lt;p&gt;Nginx in front (it's already configured)&lt;/p&gt;

&lt;p&gt;Fresh single-app VPS, you want minimum parts&lt;/p&gt;

&lt;p&gt;FrankenPHP on 443 directly&lt;/p&gt;

&lt;p&gt;You need Nginx-level rate limiting, WAF rules, or odd rewrites&lt;/p&gt;

&lt;p&gt;Nginx in front&lt;/p&gt;

&lt;p&gt;You want HTTP/3 without extra work&lt;/p&gt;

&lt;p&gt;FrankenPHP directly (Caddy ships it)&lt;/p&gt;

&lt;p&gt;The one setup to avoid: both fighting over the same ports. If Nginx holds 443 and you start FrankenPHP without telling it to use another port, it will try to bind 443, fail, and crash-loop. Explicit &lt;code&gt;--host=127.0.0.1 --port=8000&lt;/code&gt; in the unit file prevents that class of incident entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker Mode Gotchas: The Section That Saves Your Weekend
&lt;/h2&gt;

&lt;p&gt;Here's the mental shift. For PHP's entire history, every request started from a blank slate. Sloppy state management was invisible because the runtime threw your mess away every few hundred milliseconds. Worker mode removes the garbage truck. Your application object lives for hundreds or thousands of requests, and anything that leaks between requests is now a bug.&lt;/p&gt;

&lt;p&gt;In our experience, these are the ones that actually bite:&lt;/p&gt;

&lt;h3&gt;
  
  
  Static Properties and Singletons Holding Stale State
&lt;/h3&gt;

&lt;p&gt;The classic case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DiscountService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;?User&lt;/span&gt; &lt;span class="nv"&gt;$currentUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;applyFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$currentUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under PHP-FPM, this is merely ugly. Under worker mode, &lt;code&gt;$currentUser&lt;/code&gt; survives the request. Request one sets it to Alice. Request two belongs to Bob, hits a code path that reads &lt;code&gt;static::$currentUser&lt;/code&gt;, and Bob gets Alice's discount, or worse, Alice's data. This category of bug is quiet, intermittent, and horrifying, because it only reproduces under real concurrent traffic.&lt;/p&gt;

&lt;p&gt;The same applies to container singletons that capture request state. If a service is bound with &lt;code&gt;singleton()&lt;/code&gt; and its constructor receives the current request, the current user, or anything derived from either, that snapshot is frozen at first resolution and served to every subsequent request that worker handles. Bind request-dependent services with &lt;code&gt;scoped()&lt;/code&gt; instead, which Octane resets between requests, or resolve the request lazily inside the method that needs it.&lt;/p&gt;

&lt;p&gt;Before going live, audit for the usual suspects: &lt;code&gt;static&lt;/code&gt; properties that aren't pure caches of immutable data, singletons constructed from request state, and packages that memoize per-request values in globals. Legacy packages are the biggest risk, because you didn't write the state bugs you're inheriting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory That Only Grows
&lt;/h3&gt;

&lt;p&gt;Even with clean request handling, long-lived PHP processes accumulate memory. Collected log context, growing arrays in third-party clients, fragmentation. Watch your Octane service for a week and you'll see resident memory climb in a slow staircase.&lt;/p&gt;

&lt;p&gt;You have three layers of defense, and you want all three:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, **&lt;code&gt;--max-requests&lt;/code&gt;&lt;/strong&gt; is the pressure valve.** The &lt;code&gt;--max-requests=500&lt;/code&gt; flag in our unit file tells Octane to gracefully retire each worker after 500 requests and boot a fresh one. It's the same idea as &lt;code&gt;pm.max_requests&lt;/code&gt; in PHP-FPM: an admission that leaks happen, converted from an outage into routine hygiene. Start around 500 and lower it if memory still climbs too fast between recycles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, watch the numbers.&lt;/strong&gt; &lt;code&gt;systemctl status octane&lt;/code&gt; shows current memory for the whole service, and &lt;code&gt;journalctl -u octane&lt;/code&gt; shows restarts. For trends over time you want real monitoring; a leak that adds a few megabytes per hour is invisible in a status snapshot and obvious on a seven-day memory graph. This is exactly the kind of thing &lt;a href="https://deploynix.io/blog/monitoring-your-laravel-app-in-production" rel="noopener noreferrer"&gt;server-level monitoring&lt;/a&gt; with alerts exists for. Deploynix's built-in CPU/memory alerts will page you when the box crosses a threshold, which for a slow leak means you find out on a Tuesday afternoon instead of during Saturday's traffic spike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the systemd **&lt;code&gt;MemoryMax&lt;/code&gt;&lt;/strong&gt; cap** from the unit file above is the final backstop. If everything else fails, the service gets restarted rather than the server falling over.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restart Workers on Every Deploy, No Exceptions
&lt;/h3&gt;

&lt;p&gt;This is the operational rule people learn the hard way. Your workers hold the &lt;em&gt;old&lt;/em&gt; code in memory. Deploy new code, and until the workers restart, they keep serving the previous release. With atomic symlink deploys it's stricter still: the workers resolved the old release's real path at boot, so they don't even see the symlink flip.&lt;/p&gt;

&lt;p&gt;The fix is one line in your deploy hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan octane:reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gracefully cycles the workers into the new code without dropping in-flight requests. In a Deploynix deployment hook, it slots in right after the symlink swap, alongside the &lt;code&gt;queue:restart&lt;/code&gt; you should already be running for the same reason. If you're on Horizon, the same stale-code logic applies to queue workers, and the deploy hook handles both.&lt;/p&gt;

&lt;p&gt;Forget this step and you get the most confusing bug class in Octane operations: "I deployed the fix but production still has the bug." You'll check the code on disk, it's correct, and you'll question your sanity for twenty minutes before remembering the workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Performance Should You Actually Expect?
&lt;/h2&gt;

&lt;p&gt;Time for honest framing, because this is where Octane marketing and Octane reality diverge.&lt;/p&gt;

&lt;p&gt;What worker mode eliminates is per-request framework bootstrap: loading config, registering service providers, building the container. On a hot, simple route (an API endpoint returning a small JSON payload, a lightweight authenticated page) that bootstrap is a large fraction of total response time, and removing it is a dramatic, measurable win. This is the workload behind the impressive-looking demos, and the gain there is real. The &lt;a href="https://blog.laravel.com/octane-frankenphp" rel="noopener noreferrer"&gt;Laravel team's own writeup&lt;/a&gt; is a good reference for what the FrankenPHP + Octane combination is designed to deliver.&lt;/p&gt;

&lt;p&gt;But most production Laravel requests aren't hot, simple routes. They're three database queries, a cache read, maybe an external API call, and a Blade render. If your median request spends most of its time waiting on MySQL, removing the bootstrap shaves a modest slice off the total. Worker mode makes the PHP part of your request fast; it does nothing for the parts of your request that were already the actual bottleneck.&lt;/p&gt;

&lt;p&gt;So the realistic picture looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Big wins: high-RPS APIs, endpoints serving mostly cached data, apps where framework boot dominates response time, servers where FPM process churn is the limiting factor.&lt;/li&gt;
&lt;li&gt;Modest wins: typical CRUD apps that are database-bound. Latency improves, but your users may not notice.&lt;/li&gt;
&lt;li&gt;No win at all: apps whose slow endpoints are slow because of unindexed queries or chatty external APIs. Fix those first; they're cheaper fixes than a new runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We deliberately won't quote benchmark numbers here, because every "FrankenPHP is N times faster" figure was measured on someone else's app, someone else's routes, and someone else's hardware. Load test &lt;em&gt;your&lt;/em&gt; app before and after. Our &lt;a href="https://deploynix.io/blog/how-much-traffic-can-a-5-server-handle-load-testing-laravel-on-deploynix" rel="noopener noreferrer"&gt;load testing walkthrough on a $5 server&lt;/a&gt; covers a workflow you can reuse for exactly this comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Not Use FrankenPHP?
&lt;/h2&gt;

&lt;p&gt;The uncomfortable truth after all of the above: PHP-FPM remains the right default for most Laravel apps. FrankenPHP worker mode is a trade: you accept a stricter programming model and a new operational surface in exchange for throughput and latency. That trade only makes sense when the throughput actually buys you something.&lt;/p&gt;

&lt;p&gt;Skip worker mode (or defer it) when:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your codebase assumes per-request state.&lt;/strong&gt; If the app is older, has accumulated static properties, or leans on packages you haven't audited for long-lived processes, worker mode will surface those assumptions as intermittent production bugs. The audit is real work. Until you've done it, FPM's blank-slate model is protecting you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You depend on legacy or unmaintained packages.&lt;/strong&gt; You can fix your own singletons. You can't easily fix a package that stashes state in a static property four dependencies deep. If your &lt;code&gt;composer.json&lt;/code&gt; has archaeology in it, test under sustained load, not just a smoke test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You don't have memory monitoring.&lt;/strong&gt; Worker mode without a memory graph is flying without instruments. If nobody on the team will notice RSS climbing over a week, the first symptom of a leak will be an OOM kill during peak traffic. Set up monitoring first, Octane second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The site is low-traffic.&lt;/strong&gt; A brochure site, an internal tool, an app serving a few requests per second: FPM handles this without breaking a sweat, and it's simpler in every way that matters. Adding a long-lived application server to save milliseconds nobody is waiting for is complexity with no payoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your bottleneck is elsewhere.&lt;/strong&gt; Slow queries, missing indexes, no cache layer, a fat external API in the request path. Worker mode won't rescue any of that, and it's harder to debug the real problem through a new runtime.&lt;/p&gt;

&lt;p&gt;The right time for FrankenPHP is when you have real traffic, a codebase you trust (or have audited) to be stateless per request, monitoring already in place, and a measured bottleneck that's actually PHP execution. That describes fewer apps than the hype suggests, but for those apps it's excellent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting Quick Hits
&lt;/h2&gt;

&lt;p&gt;The issues you'll most likely hit in the first week, and their fixes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service crash-loops immediately on start.&lt;/strong&gt; Almost always a port conflict. Nginx already holds 80/443 and FrankenPHP tried to bind them. Check with &lt;code&gt;sudo ss -tlnp | grep -E ':(80|443|8000)'&lt;/code&gt; and make sure your &lt;code&gt;--host=127.0.0.1 --port=8000&lt;/code&gt; flags actually made it into the unit file. Also watch for Caddy's admin API on port 2019 colliding if you run more than one instance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Failed to create config directory" or permission errors under systemd.&lt;/strong&gt; The XDG variables are missing. Add &lt;code&gt;Environment=XDG_CONFIG_HOME=&lt;/code&gt; and &lt;code&gt;Environment=XDG_DATA_HOME=&lt;/code&gt; lines pointing somewhere your service user can write, as in the unit above. It works from your shell and fails under systemd because your shell has &lt;code&gt;$HOME&lt;/code&gt; conveniences the service doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployed code not live.&lt;/strong&gt; Workers are serving the old release from memory. Run &lt;code&gt;php artisan octane:reload&lt;/code&gt; and add it to your deploy hook permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading logs.&lt;/strong&gt; Everything the service prints goes to the journal: &lt;code&gt;journalctl -u octane -f&lt;/code&gt; to follow live, &lt;code&gt;journalctl -u octane --since "1 hour ago"&lt;/code&gt; when investigating an incident. Laravel's own log channel still writes wherever you configured it; check both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory climbing steadily.&lt;/strong&gt; Expected to a degree. Confirm &lt;code&gt;--max-requests&lt;/code&gt; is set, watch whether recycling flattens the staircase, and if a single worker balloons on a specific route, that route is leaking: profile it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weird redirects or wrong URLs behind Nginx.&lt;/strong&gt; You're missing &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, so Laravel thinks it's serving HTTP. Confirm the proxy headers from the Nginx block above and that your &lt;code&gt;TrustProxies&lt;/code&gt; middleware trusts &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves You
&lt;/h2&gt;

&lt;p&gt;FrankenPHP earned its position as the default Octane story honestly: one binary, no extension to compile, no separate config language, and Caddy's HTTPS automation included. On a plain VPS the full production setup is a binary, a systemd unit, an Nginx &lt;code&gt;proxy_pass&lt;/code&gt; block, and one line in your deploy hook. That's a smaller footprint than either Swoole or RoadRunner asks for.&lt;/p&gt;

&lt;p&gt;The discipline it demands is real, though. Worker mode changes PHP's oldest contract, and the price of the speed is that your code must actually be stateless between requests, your deploys must reload workers, and someone must watch the memory graph.&lt;/p&gt;

&lt;p&gt;If you're running on Deploynix, the surrounding pieces are already in place: Nginx fronts your site, atomic symlink deploys give &lt;code&gt;octane:reload&lt;/code&gt; its natural home in a deployment hook, daemons keep the process supervised, and server monitoring catches the memory creep worker mode makes possible. The FrankenPHP-specific parts of this guide still apply verbatim; the platform just handles the scaffolding around them.&lt;/p&gt;

&lt;p&gt;And if after reading the "when not to use it" section you concluded that PHP-FPM is fine for your app, that's not a consolation prize. It's the correct engineering call for most Laravel applications in 2026. Revisit the decision when your traffic, not your curiosity, asks for it.&lt;/p&gt;

</description>
      <category>frankenphp</category>
      <category>laraveloctane</category>
      <category>performance</category>
      <category>php</category>
    </item>
    <item>
      <title>SQLite in Production for Laravel: When One File Wins</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Wed, 15 Jul 2026 19:14:08 +0000</pubDate>
      <link>https://dev.to/deploynix/sqlite-in-production-for-laravel-when-one-file-wins-2pi5</link>
      <guid>https://dev.to/deploynix/sqlite-in-production-for-laravel-when-one-file-wins-2pi5</guid>
      <description>&lt;p&gt;SQLite has been Laravel's default database since &lt;a href="https://laravel.com/docs/11.x/releases" rel="noopener noreferrer"&gt;Laravel 11 shipped in 2024&lt;/a&gt;. Run &lt;code&gt;laravel new&lt;/code&gt;, hit enter through the prompts, and your app is talking to a single file on disk. No daemon, no port 3306, no credentials to rotate.&lt;/p&gt;

&lt;p&gt;Most reactions to this fall into two camps. One camp treats SQLite as a toy you swap out before your first real user. The other treats it as a silver bullet that makes database servers obsolete. Both are wrong, and both will cost you either money or sleep.&lt;/p&gt;

&lt;p&gt;This is the guide for the middle ground: you run Laravel on a VPS, you want fewer moving parts, and you need to know exactly what SQLite demands in production, where it breaks, and when to walk away from it. Real config blocks throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SQLite Suddenly Got Respectable
&lt;/h2&gt;

&lt;p&gt;Five years ago, suggesting SQLite for production in a Laravel forum got you laughed out of the thread. Three things changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel made it the default.&lt;/strong&gt; That single decision moved SQLite from "testing database" to "the thing thousands of production apps launched on." The framework followed through: the modern &lt;code&gt;config/database.php&lt;/code&gt; exposes SQLite pragmas as first-class connection options, which is most of what production tuning requires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Modern VPS hardware removed the old bottleneck.&lt;/strong&gt; SQLite's classic weakness was slow disks. NVMe storage is now standard on entry-level instances. A Hetzner CPX-class box with 4 GB of RAM runs around &lt;a href="https://betterstack.com/community/guides/web-servers/digitalocean-vs-hetzner/" rel="noopener noreferrer"&gt;€7.99/month, against $24/month for the equivalent DigitalOcean droplet&lt;/a&gt;, and either one gives you disk latency measured in microseconds. A query against a local NVMe file skips the network hop entirely. There's no connection pool, no TCP handshake, no round trip. A read is a function call into a library living in your PHP process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replication tooling matured.&lt;/strong&gt; The strongest argument against SQLite was always "one file, one disk, one bad day away from losing everything." Tools like &lt;a href="https://litestream.io" rel="noopener noreferrer"&gt;Litestream&lt;/a&gt; changed that math by streaming every change to S3-compatible storage continuously. We'll set that up below.&lt;/p&gt;

&lt;p&gt;None of this makes SQLite the right answer everywhere. It makes SQLite a serious option for a specific shape of application: single server, read-heavy, modest write volume. That shape covers more Laravel apps than most people admit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Production Config Laravel Actually Needs
&lt;/h2&gt;

&lt;p&gt;SQLite's out-of-the-box defaults are conservative to the point of being hostile for web workloads. The default journal mode locks the entire database during writes, and the default busy timeout is zero, meaning a second concurrent write throws &lt;code&gt;SQLITE_BUSY: database is locked&lt;/code&gt; immediately instead of waiting its turn.&lt;/p&gt;

&lt;p&gt;Laravel lets you fix all of this declaratively. Here's the connection block that should be in every production &lt;code&gt;config/database.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'sqlite'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sqlite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;database_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'database.sqlite'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="s1"&gt;'prefix'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'foreign_key_constraints'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_FOREIGN_KEYS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'busy_timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'journal_mode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'wal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'synchronous'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'normal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four settings do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;journal_mode =&amp;gt; 'wal'&lt;/code&gt;: switches to Write-Ahead Logging. This is the single most important line. Without it, every write blocks every read. More on what WAL actually changes in the next section.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;busy_timeout =&amp;gt; 5000&lt;/code&gt;: when a connection hits a lock, it retries for up to 5 seconds before giving up instead of failing instantly. This turns "random 500 errors under load" into "a write occasionally waits a few extra milliseconds." It's the difference between SQLite feeling flaky and feeling boring.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;synchronous =&amp;gt; 'normal'&lt;/code&gt;: the default &lt;code&gt;FULL&lt;/code&gt; setting forces an fsync on every transaction. &lt;code&gt;NORMAL&lt;/code&gt; in WAL mode syncs at checkpoint boundaries instead. In our experience this is the right trade: the database can't corrupt on power loss, though the last few transactions before a hard crash may be lost. If you're running a payments ledger, keep &lt;code&gt;FULL&lt;/code&gt;. For everything else, &lt;code&gt;NORMAL&lt;/code&gt; is noticeably faster.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;foreign_key_constraints =&amp;gt; true&lt;/code&gt;: SQLite ships with foreign key enforcement off for backwards-compatibility reasons that date to 2009. Turn it on. You want the database rejecting orphaned rows, not your application code hoping to catch them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One warning that saves people real pain: WAL mode requires a local filesystem. It relies on shared memory between processes, and it does not work correctly on NFS or other network filesystems. If your database file lives on network-attached storage, you've quietly reintroduced the network hop you were trying to eliminate, plus a corruption risk. Local disk only.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does WAL Mode Actually Change?
&lt;/h2&gt;

&lt;p&gt;In the default rollback-journal mode, a write transaction takes an exclusive lock on the whole database. Readers wait for writers. Writers wait for readers. On a web server handling concurrent requests, that's a queue of PHP-FPM workers stacking up behind a single &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;WAL inverts the model. Writes get appended to a separate &lt;code&gt;-wal&lt;/code&gt; file, and readers keep reading a consistent snapshot of the main file. The practical result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Readers never block writers, and writers never block readers. Your traffic can keep serving pages while a write is in flight.&lt;/li&gt;
&lt;li&gt;There is still exactly one writer at a time. This is the constraint that never goes away. Two simultaneous writes serialize; the second one waits (which is why &lt;code&gt;busy_timeout&lt;/code&gt; matters).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a typical Laravel app, where the overwhelming majority of queries are reads, this constraint is far less scary than it sounds. A blog, a documentation site, a dashboard, a booking tool with a few hundred writes a minute: none of these come close to saturating a single writer on NVMe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where write contention actually bites
&lt;/h3&gt;

&lt;p&gt;The apps that hurt on SQLite aren't the ones with lots of users. They're the ones where the framework itself hammers the database with writes. And here's the trap: recent Laravel defaults put the queue, the cache, and sessions in the database. On MySQL that's a reasonable default. On SQLite it means every queued job, every cache write, and every session touch competes for the same single-writer lock as your actual business data.&lt;/p&gt;

&lt;p&gt;The fix is to give the chatty infrastructure workloads to Redis or Valkey, which is a one-line install on most VPS setups, and reserve SQLite for the data you actually care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sqlite&lt;/span&gt;
&lt;span class="py"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/deploynix/example.com/shared/database/production.sqlite&lt;/span&gt;

&lt;span class="py"&gt;QUEUE_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;redis&lt;/span&gt;
&lt;span class="py"&gt;CACHE_STORE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;redis&lt;/span&gt;
&lt;span class="py"&gt;SESSION_DRIVER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;redis&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that split, SQLite holds users, orders, and posts, while the thousands of ephemeral writes per minute go to a store built for exactly that. If your queue volume is serious, &lt;a href="https://deploynix.io/blog/laravel-queues-deep-dive-connections-workers-and-retry-strategies" rel="noopener noreferrer"&gt;our Laravel queues guide&lt;/a&gt; covers connection and worker tuning in detail. The short version: never point a busy queue at your SQLite file. It works right up until it doesn't, and the failure mode is your whole app slowing down together.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Far Does One File Actually Go?
&lt;/h2&gt;

&lt;p&gt;Honest answer: further than you think, and we're deliberately not going to invent benchmark numbers here, because SQLite throughput depends almost entirely on your write mix and your disk.&lt;/p&gt;

&lt;p&gt;The qualitative framing that holds up in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read-heavy apps rarely hit the wall. The SQLite project itself states that any site getting fewer than 100,000 hits per day should work fine with SQLite, and describes that as a conservative estimate. On modern NVMe hardware with WAL mode, read throughput is bounded by your PHP layer long before the database.&lt;/li&gt;
&lt;li&gt;Write-heavy apps hit the single-writer limit. If your core workload is high-frequency inserts from many concurrent sources (event ingestion, chat, real-time analytics), writes serialize and latency climbs. No pragma fixes an architectural mismatch.&lt;/li&gt;
&lt;li&gt;The database size ceiling is not your problem. SQLite handles databases in the tens of gigabytes without drama. You'll outgrow the operational model (one server, one file) long before you outgrow the file format.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful mental test: pull up your production query log and count writes per second at peak, excluding cache, sessions, and queue traffic. If the number is in the single or low double digits, SQLite will not be your bottleneck. If it's in the hundreds and climbing, read the graduation section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups: The Part Everyone Gets Wrong
&lt;/h2&gt;

&lt;p&gt;Here's the trap that catches almost everyone moving SQLite to production: &lt;code&gt;cp database.sqlite backup.sqlite&lt;/code&gt; is not a safe backup.&lt;/p&gt;

&lt;p&gt;If a write transaction is in flight while the copy runs, you can capture a torn, inconsistent snapshot. Worse, in WAL mode, recent committed transactions live in the &lt;code&gt;-wal&lt;/code&gt; sidecar file. Copy only the main file and your "backup" is silently missing the newest data. It will restore. It will look fine. It will be wrong.&lt;/p&gt;

&lt;p&gt;SQLite gives you two correct tools instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-shot and scheduled backups
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.backup&lt;/code&gt; command uses SQLite's online backup API, which produces a consistent snapshot even while the app is writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 /home/deploynix/example.com/shared/database/production.sqlite &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;".backup '/home/deploynix/backups/production-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="se"&gt;\%&lt;/span&gt;Y&lt;span class="se"&gt;\%&lt;/span&gt;m&lt;span class="se"&gt;\%&lt;/span&gt;d-&lt;span class="se"&gt;\%&lt;/span&gt;H&lt;span class="se"&gt;\%&lt;/span&gt;M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sqlite'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alternative is &lt;code&gt;VACUUM INTO&lt;/code&gt;, which also produces a consistent copy and compacts it at the same time, dropping the dead space left by deleted rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 /home/deploynix/example.com/shared/database/production.sqlite &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"VACUUM INTO '/home/deploynix/backups/production-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="se"&gt;\%&lt;/span&gt;Y&lt;span class="se"&gt;\%&lt;/span&gt;m&lt;span class="se"&gt;\%&lt;/span&gt;d&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sqlite'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Deploynix, the practical setup is a scheduled cron job created from the dashboard: run one of the commands above nightly, then push the file to S3-compatible storage with &lt;code&gt;rclone&lt;/code&gt; or the AWS CLI, and prune anything older than your retention window. Wasabi or a DigitalOcean Space costs pennies a month at typical SQLite database sizes. Ten minutes of setup, and you have the same offsite-backup posture a managed MySQL setup gives you.&lt;/p&gt;

&lt;p&gt;Note the escaped &lt;code&gt;%&lt;/code&gt; signs in those commands. In a crontab, a bare &lt;code&gt;%&lt;/code&gt; is interpreted as a newline, and the resulting failure is silent. If you paste the command into a shell script that cron invokes, drop the backslashes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Continuous replication with Litestream
&lt;/h3&gt;

&lt;p&gt;Nightly backups mean you can lose up to a day of data. For anything with real users, run &lt;a href="https://litestream.io" rel="noopener noreferrer"&gt;Litestream&lt;/a&gt; as well. It sits alongside your app, watches the WAL, and continuously streams every change to S3-compatible storage. Recovery point drops from "last night" to "a few seconds ago."&lt;/p&gt;

&lt;p&gt;Install it and drop a config at &lt;code&gt;/etc/litestream.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/home/deploynix/example.com/shared/database/production.sqlite&lt;/span&gt;
    &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;
        &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-litestream&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
        &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://s3.eu-central-1.wasabisys.com&lt;/span&gt;
        &lt;span class="na"&gt;access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${LITESTREAM_ACCESS_KEY_ID}&lt;/span&gt;
        &lt;span class="na"&gt;secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${LITESTREAM_SECRET_ACCESS_KEY}&lt;/span&gt;
        &lt;span class="na"&gt;retention&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;72h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Litestream needs to run permanently, and this is where a process supervisor earns its keep. The packaged systemd unit works fine, or on Deploynix you can register &lt;code&gt;litestream replicate&lt;/code&gt; as a daemon and let Supervisor handle restarts, exactly the way you'd run Horizon.&lt;/p&gt;

&lt;p&gt;Disaster recovery is a single command against an empty path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;litestream restore &lt;span class="nt"&gt;-o&lt;/span&gt; /home/deploynix/example.com/shared/database/production.sqlite &lt;span class="se"&gt;\&lt;/span&gt;
  s3://myapp-litestream/production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do a test restore once, before you need it. A replication pipeline you've never restored from is a hypothesis, not a backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deployment Gotcha That Deletes Your Database
&lt;/h2&gt;

&lt;p&gt;This is the SQLite production mistake we see most often, and it's brutal because everything works perfectly until your second deploy.&lt;/p&gt;

&lt;p&gt;Zero-downtime deployment works by building each release in a fresh directory and atomically swapping a &lt;code&gt;current&lt;/code&gt; symlink, which is &lt;a href="https://deploynix.io/blog/the-anatomy-of-a-zero-downtime-deploy" rel="noopener noreferrer"&gt;how atomic deploys are structured&lt;/a&gt; on Deploynix and every similar tool. Now think about where Laravel puts SQLite by default: &lt;code&gt;database/database.sqlite&lt;/code&gt;, inside the application directory. Inside the release.&lt;/p&gt;

&lt;p&gt;Deploy once, and your database is born inside &lt;code&gt;releases/20260713101500/&lt;/code&gt;. Deploy again, and the new release gets a brand-new empty file while your real data sits stranded in the previous release directory, waiting for the release-cleanup step to delete it entirely. Users vanish. Orders vanish. It looks exactly like a catastrophic data loss bug, and it's just a path.&lt;/p&gt;

&lt;p&gt;The fix is the same pattern used for &lt;code&gt;storage/&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt;: the database lives in the shared directory that survives every deploy, and the app points at it by absolute path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/home/deploynix/example.com/
├── releases/
│   ├── 20260712091412/
│   └── 20260713101500/
├── shared/
│   ├── storage/
│   ├── .env
│   └── database/
│       └── production.sqlite       releases/20260713101500
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the shared &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;DB_DATABASE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/deploynix/example.com/shared/database/production.sqlite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a deployment hook that guarantees the directory exists before migrations run, so the very first deploy on a fresh server doesn't fall over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/deploynix/example.com/shared/database
&lt;span class="nb"&gt;touch&lt;/span&gt; /home/deploynix/example.com/shared/database/production.sqlite
php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two smaller deployment notes worth knowing. First, migrations that rebuild tables (SQLite implements some &lt;code&gt;ALTER TABLE&lt;/code&gt; operations as create-copy-swap) briefly hold the write lock; your configured &lt;code&gt;busy_timeout&lt;/code&gt; means in-flight requests wait a few seconds rather than erroring, which is exactly the behavior you want mid-deploy. Second, remember that the &lt;code&gt;-wal&lt;/code&gt; and &lt;code&gt;-shm&lt;/code&gt; sidecar files live next to the database. They belong in &lt;code&gt;shared/database/&lt;/code&gt; too, and no backup script or deploy hook should ever touch them independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Graduate to MySQL or Postgres?
&lt;/h2&gt;

&lt;p&gt;SQLite in production isn't a religion. It's a phase, and for plenty of apps it's a permanent one. But there are clear, unambiguous signals that the phase is over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need a second app server. This is the hard line. SQLite is a local file; two servers can't share it safely, and the workarounds (network filesystems, distributed SQLite layers) trade away the simplicity that justified SQLite in the first place. The moment horizontal scaling is on the roadmap, plan the migration.&lt;/li&gt;
&lt;li&gt;Sustained concurrent writes are your core workload. If profiling shows writers regularly queuing behind &lt;code&gt;busy_timeout&lt;/code&gt; at normal traffic, not just during spikes, you've outgrown the single-writer model.&lt;/li&gt;
&lt;li&gt;You need read replicas, failover, or point-in-time recovery beyond what Litestream offers. A client-server database makes these first-class features rather than bolt-ons.&lt;/li&gt;
&lt;li&gt;Your team or your compliance auditors need standard database tooling. Access control per user, audit logging, and established DBA workflows all assume a database server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration itself is usually less painful than people fear, provided you stayed inside Eloquent and the query builder. Export with &lt;code&gt;sqlite3 .dump&lt;/code&gt;, massage the schema, or more simply: point your migrations at the new connection, run them fresh, and write a one-off command that copies rows table by table. Apps that avoided driver-specific raw SQL typically move in an afternoon.&lt;/p&gt;

&lt;p&gt;Choosing what to graduate to is its own decision. Our &lt;a href="https://deploynix.io/blog/mysql-vs-mariadb-vs-postgresql-on-deploynix-which-should-you-pick" rel="noopener noreferrer"&gt;MySQL vs. MariaDB vs. PostgreSQL comparison&lt;/a&gt; breaks that down; the short version is that MySQL is the safe default for Laravel and Postgres wins when you need richer types and querying. Either way, provisioning a dedicated database server next to your app server is a few clicks, and if you're cost-sensitive about running a second box, &lt;a href="https://deploynix.io/blog/hetzner-deploynix-the-best-price-to-performance-combo" rel="noopener noreferrer"&gt;Hetzner's price-to-performance&lt;/a&gt; keeps the graduation cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Decision Checklist
&lt;/h2&gt;

&lt;p&gt;Run your app through this before committing either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite is a strong fit if all of these are true:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One application server, and no multi-server plans for the next year or so&lt;/li&gt;
&lt;li&gt;Read-heavy workload; peak writes per second in the low double digits or below&lt;/li&gt;
&lt;li&gt;Queues, cache, and sessions are on Redis or Valkey, not the database&lt;/li&gt;
&lt;li&gt;The database file lives on local NVMe, not network storage&lt;/li&gt;
&lt;li&gt;You've set up WAL mode, &lt;code&gt;busy_timeout&lt;/code&gt;, and either Litestream or scheduled &lt;code&gt;.backup&lt;/code&gt; jobs&lt;/li&gt;
&lt;li&gt;The database file lives in shared storage, outside your releases directory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pick MySQL or Postgres from day one if any of these are true:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Load balancer and multiple app servers, now or soon&lt;/li&gt;
&lt;li&gt;High-frequency concurrent writes are the product (ingestion, messaging, telemetry)&lt;/li&gt;
&lt;li&gt;You need replicas, managed failover, or granular database-level access control&lt;/li&gt;
&lt;li&gt;Ops or compliance requirements assume a client-server database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what's not on either list: user count, revenue, or how "serious" the project is. Those are the wrong axes. The right axes are write concurrency and server topology.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;SQLite in production for Laravel is neither hype nor heresy. It's a precise engineering trade: you give up multi-server topologies and heavy concurrent writes, and in exchange you get zero network latency, one less service to patch and monitor, and a database that backs up as a single file. For a single-VPS Laravel app with sane write volume, that trade is genuinely good, and the framework's defaults now reflect it.&lt;/p&gt;

&lt;p&gt;The production checklist is short and non-negotiable: WAL mode with &lt;code&gt;busy_timeout&lt;/code&gt; and &lt;code&gt;synchronous=NORMAL&lt;/code&gt;, infrastructure workloads on Redis, &lt;code&gt;.backup&lt;/code&gt; or Litestream instead of &lt;code&gt;cp&lt;/code&gt;, and the database file in shared storage where atomic deploys can't orphan it. Every one of those steps fits naturally into a Deploynix setup: scheduled crons for backups, Supervisor daemons for Litestream, deployment hooks for the shared path, and one-click rollback for everything else. Get those four things right and one file really does beat a database server, right up until the day it doesn't, and you'll see that day coming from a long way off.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>laravel</category>
      <category>database</category>
      <category>production</category>
    </item>
    <item>
      <title>Upgrading to Laravel 13 in Production with Zero Downtime</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Tue, 14 Jul 2026 16:20:15 +0000</pubDate>
      <link>https://dev.to/deploynix/upgrading-to-laravel-13-in-production-with-zero-downtime-2l4n</link>
      <guid>https://dev.to/deploynix/upgrading-to-laravel-13-in-production-with-zero-downtime-2l4n</guid>
      <description>&lt;p&gt;Laravel 13 shipped on March 17, 2026, and it's the friendliest major upgrade the framework has had in years: effectively &lt;a href="https://laravel.com/docs/13.x/releases" rel="noopener noreferrer"&gt;zero breaking changes&lt;/a&gt;, with the only hard requirement being PHP 8.3 or newer. The new toys are real too, including first-party AI primitives, native attribute syntax, JSON:API resources, and vector search support, all covered in the &lt;a href="https://laravel.com/docs/13.x/releases" rel="noopener noreferrer"&gt;official release notes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's the part that should get your attention: &lt;a href="https://laravel.com/docs/13.x/releases" rel="noopener noreferrer"&gt;bug fixes for Laravel 12 end in August 2026&lt;/a&gt;. From where we're sitting in July, that's next month. After that, only security patches land on 12, and every framework-level bug you hit stays yours to work around.&lt;/p&gt;

&lt;p&gt;So the upgrade window is now. But "run &lt;code&gt;composer update&lt;/code&gt; and hope" is not a production strategy. This is a playbook for doing the upgrade on live servers, with real traffic flowing, without a maintenance page and without a single dropped request. We've run this process across dozens of production apps, and the steps below are in the order that actually keeps you safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why August 2026 Is Your Deadline
&lt;/h2&gt;

&lt;p&gt;Laravel's support policy is simple: &lt;a href="https://laravel.com/docs/13.x/releases#support-policy" rel="noopener noreferrer"&gt;each major release gets roughly 18 months of bug fixes and two years of security fixes&lt;/a&gt;. Laravel 12's bug-fix window &lt;a href="https://laravel.com/docs/13.x/releases" rel="noopener noreferrer"&gt;closes in August 2026&lt;/a&gt;. Laravel 13's runs well into 2027.&lt;/p&gt;

&lt;p&gt;That matters more than it sounds. Bug-fix EOL is the point where "we'll upgrade next quarter" turns into "we're patching vendor code in a fork." If a queue serialization edge case or an Eloquent regression bites you in September, the fix ships for 13, not for 12.&lt;/p&gt;

&lt;p&gt;The good news is that the 12 to 13 jump is unusually cheap. The Laravel team explicitly designed it as a low-friction release. No renamed core methods, no removed helpers, no directory reshuffle. In our experience, the framework upgrade itself is a one-hour job for most apps. The production rollout is where teams get burned, and that's what the rest of this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Verify PHP on the Server, Not Just Your Laptop
&lt;/h2&gt;

&lt;p&gt;Laravel 13 requires PHP 8.3 at minimum. Your laptop probably has 8.4 already. Your production box is the one that will lie to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI and FPM Are Two Different PHPs
&lt;/h3&gt;

&lt;p&gt;A server can happily run PHP 8.4 on the command line while Nginx routes every web request to a PHP 8.2 FPM pool. Check both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What the CLI runs (this is what artisan and composer use)&lt;/span&gt;
php &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;span class="c"&gt;# What FPM pools are actually installed and running&lt;/span&gt;
systemctl list-units &lt;span class="s1"&gt;'php*-fpm*'&lt;/span&gt;

&lt;span class="c"&gt;# What your site actually uses: check the socket in your Nginx vhost&lt;/span&gt;
&lt;span class="nb"&gt;grep &lt;/span&gt;fastcgi_pass /etc/nginx/sites-enabled/your-app.conf
&lt;span class="c"&gt;# fastcgi_pass unix:/run/php/php8.2-fpm.sock;    releases/20260710091500&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Build Everything in the New Release First
&lt;/h3&gt;

&lt;p&gt;Every expensive or risky operation happens inside the new release directory while the old one keeps serving traffic untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RELEASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/home/deploy/app/releases/20260713104500
git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 &lt;span class="nt"&gt;--branch&lt;/span&gt; main git@github.com:acme/app.git &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-nfs&lt;/span&gt; /home/deploy/app/shared/.env &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;/storage"&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-nfs&lt;/span&gt; /home/deploy/app/shared/storage &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;/storage"&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--prefer-dist&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt;

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the caches are rebuilt inside the new release, before the swap. This matters more during a framework upgrade than on any normal deploy: &lt;code&gt;bootstrap/cache/config.php&lt;/code&gt; is a serialized artifact of the framework version that generated it. A Laravel 12 config cache read by Laravel 13 code, or the reverse, is the classic source of "it deployed fine and then everything 500'd." Because caches live per-release, the live Laravel 12 release keeps its own caches and never sees the new ones.&lt;/p&gt;

&lt;p&gt;One deliberate omission: there is no &lt;code&gt;php artisan down&lt;/code&gt; anywhere in this process. Maintenance mode exists for deploys that mutate shared state in place. Atomic releases don't, so the maintenance page is pure self-inflicted downtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrations: Additive-Only, and Isolated
&lt;/h3&gt;

&lt;p&gt;Migrations are the one step that touches shared state, so they get their own rule for the upgrade deploy: &lt;strong&gt;additive changes only&lt;/strong&gt;. New tables, new nullable columns, new indexes. Nothing renamed, nothing dropped, no column type changes.&lt;/p&gt;

&lt;p&gt;The reason is sequencing. Migrations run before the symlink swap, which means the old Laravel 12 code serves traffic against the new schema for a window of seconds. Old code ignores a column it doesn't know about; old code crashes hard on a column that vanished. Additive migrations make that window, and your rollback, safe. Destructive cleanup ships in a separate deploy a week later, once 13 is proven.&lt;/p&gt;

&lt;p&gt;Run them from the new release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--isolated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--isolated&lt;/code&gt; flag takes a cache-backed lock so that two servers deploying at once can't both run the migrations. If you deploy to multiple app servers, it's not optional.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Swap
&lt;/h3&gt;

&lt;p&gt;The cutover is one atomic rename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-nfs&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /home/deploy/app/current-new
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; /home/deploy/app/current-new /home/deploy/app/current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mv -T&lt;/code&gt; on a symlink is atomic on Linux. Every request before it sees Laravel 12; every request after it sees Laravel 13. No request ever sees a mixture, provided your Nginx config uses &lt;code&gt;$realpath_root&lt;/code&gt; (as in the vhost above) so PHP resolves the real release path instead of caching the &lt;code&gt;current&lt;/code&gt; symlink. Follow the swap with a graceful &lt;code&gt;systemctl reload php8.4-fpm&lt;/code&gt; to flush opcache, which finishes in-flight requests before recycling workers.&lt;/p&gt;

&lt;p&gt;This releases-plus-symlink flow with per-release cache builds is exactly what Deploynix runs on every deploy, on DigitalOcean, Hetzner, Vultr, Linode, AWS, or your own VPS, with deployment hooks for the migration and cache steps. If you'd rather not maintain the bash yourself, that's the button. The &lt;a href="https://deploynix.io/blog/the-definitive-guide-to-laravel-deployment-in-2026" rel="noopener noreferrer"&gt;definitive guide to Laravel deployment in 2026&lt;/a&gt; walks through the full pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Restart Everything That Holds Code in Memory
&lt;/h2&gt;

&lt;p&gt;The symlink swap updates what Nginx and FPM serve. It does nothing for long-lived processes that loaded your code at boot and kept it in memory. This is the single most common way a "successful" Laravel 13 deploy fails in production.&lt;/p&gt;

&lt;p&gt;Picture it: your web tier is on 13, but a Horizon worker started yesterday is still executing Laravel 12 framework code, reading jobs that your new code serialized with 13's payload expectations, resolving config through a bootstrapped 12 kernel. You get intermittent job failures that don't reproduce anywhere, because they depend on which worker picks up the job. We've seen teams chase this for days.&lt;/p&gt;

&lt;p&gt;So the swap is immediately followed by:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Horizon: finishes current jobs, exits, Supervisor boots it on the new code&lt;/span&gt;
php artisan horizon:terminate

&lt;span class="c"&gt;# Plain queue workers, if you run any outside Horizon&lt;/span&gt;
php artisan queue:restart

&lt;span class="c"&gt;# Scheduler needs nothing: each tick is a fresh process via cron&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both commands are graceful. Workers complete the job in hand before exiting, so nothing is lost mid-flight, and Supervisor restarts them inside the new release path. If Supervisor's &lt;code&gt;command&lt;/code&gt; points at a hardcoded old release directory instead of &lt;code&gt;current&lt;/code&gt;, fix that first; it's a rollback landmine. Deploynix manages Horizon and queue daemons through Supervisor and issues the terminate as a post-deploy step automatically; there's a full walkthrough in &lt;a href="https://deploynix.io/blog/running-laravel-horizon-on-deploynix" rel="noopener noreferrer"&gt;Running Laravel Horizon on Deploynix&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Running Octane? Same principle, different command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan octane:reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Octane with FrankenPHP or Swoole keeps the entire framework booted in memory between requests, so a stale Octane master is even worse than a stale queue worker: it's your whole web tier serving old code after the "deploy" finished. The reload recycles workers gracefully. More on those trade-offs in &lt;a href="https://deploynix.io/blog/php-fpm-vs-laravel-octane-on-deploynix" rel="noopener noreferrer"&gt;PHP-FPM vs. Laravel Octane&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rollback Plan
&lt;/h2&gt;

&lt;p&gt;You don't have a rollback plan unless you've defined it before the deploy. For an atomic-release upgrade, the plan is short because the design did the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code rollback: point the symlink back.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-nfs&lt;/span&gt; /home/deploy/app/releases/20260710091500 /home/deploy/app/current-new
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; /home/deploy/app/current-new /home/deploy/app/current
&lt;span class="nb"&gt;cd&lt;/span&gt; /home/deploy/app/current &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; php artisan horizon:terminate &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; php artisan queue:restart
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload php8.4-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen seconds, and you're back on Laravel 12. This works because the old release directory is completely intact: its own vendor directory, its own &lt;code&gt;composer.lock&lt;/code&gt;, its own config caches. You never rolled back by downgrading packages in place, which is slow, and fails at the worst possible moment. The lockfile in each release is your time machine; keep at least three old releases around. In Deploynix this is the one-click rollback button doing the same symlink move plus the worker restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database rollback: don't.&lt;/strong&gt; The schema is the real risk in any upgrade deploy, and the answer is that you already de-risked it in Step 4. Because your migrations were additive-only, Laravel 12 code runs perfectly well against the post-upgrade schema; the extra columns just sit there unread. Running &lt;code&gt;migrate:rollback&lt;/code&gt; on a production database under incident pressure is how a five-minute code rollback becomes a data-loss postmortem. Roll code back freely; roll schema forward only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pre-Flight Checklist
&lt;/h2&gt;

&lt;p&gt;Print this, or paste it into the pull request description:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Production PHP verified at 8.3+ on both CLI and the FPM pool Nginx actually uses&lt;/li&gt;
&lt;li&gt;[ ] PHP upgrade (if needed) shipped and soaked as its own deploy, days before the framework upgrade&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;composer why-not laravel/framework 13.0&lt;/code&gt; returns nothing&lt;/li&gt;
&lt;li&gt;[ ] Framework and first-party packages (Horizon, Reverb, Sanctum, Pest) bumped in one lockfile change&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;composer audit&lt;/code&gt; clean; lockfile diff reviewed for unfamiliar packages&lt;/li&gt;
&lt;li&gt;[ ] Full test suite green in CI on the production PHP version&lt;/li&gt;
&lt;li&gt;[ ] Queued job serialize/deserialize covered by a test&lt;/li&gt;
&lt;li&gt;[ ] Deploy uses atomic releases; &lt;code&gt;config:cache&lt;/code&gt;, &lt;code&gt;route:cache&lt;/code&gt;, &lt;code&gt;view:cache&lt;/code&gt; run inside the new release before the swap&lt;/li&gt;
&lt;li&gt;[ ] No &lt;code&gt;php artisan down&lt;/code&gt; in the deploy script&lt;/li&gt;
&lt;li&gt;[ ] Migrations in this deploy are additive-only; destructive changes parked for a follow-up&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;migrate --force --isolated&lt;/code&gt; if more than one server deploys&lt;/li&gt;
&lt;li&gt;[ ] Post-swap: &lt;code&gt;horizon:terminate&lt;/code&gt; / &lt;code&gt;queue:restart&lt;/code&gt; / &lt;code&gt;octane:reload&lt;/code&gt; as applicable, plus FPM reload&lt;/li&gt;
&lt;li&gt;[ ] Supervisor programs point at &lt;code&gt;current&lt;/code&gt;, not a hardcoded release path&lt;/li&gt;
&lt;li&gt;[ ] Previous release retained; rollback command written down before the deploy starts&lt;/li&gt;
&lt;li&gt;[ ] Error tracker and server monitoring open in a tab for thirty minutes after cutover&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deploy on a quiet weekday morning, not Friday at 5 p.m. You want a full working day of real traffic on Laravel 13 while everyone's at their desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves You
&lt;/h2&gt;

&lt;p&gt;Laravel 13 is the rare major version where the framework side of the upgrade is nearly free: &lt;a href="https://laravel.com/docs/13.x/releases" rel="noopener noreferrer"&gt;no meaningful breaking changes, a PHP 8.3 floor, and 12's bug fixes ending in August 2026&lt;/a&gt;. The engineering work isn't in the version bump. It's in the rollout: knowing what PHP your FPM pool really runs, building caches per release, keeping migrations additive, and never letting a stale worker execute old code against new state.&lt;/p&gt;

&lt;p&gt;Do it in that order and the upgrade is an anticlimax, which is exactly what a production upgrade should be. Your users see nothing. Your error tracker sees nothing. Next month, when Laravel 12's bug-fix window closes, you're already on the version that gets the fixes.&lt;/p&gt;

&lt;p&gt;If the atomic releases, symlink swaps, Horizon restarts, and one-click rollbacks are the part you'd rather not hand-maintain in bash, that's the layer &lt;a href="https://deploynix.io/#pricing" rel="noopener noreferrer"&gt;Deploynix&lt;/a&gt; automates on any server you point it at, from a $5 Hetzner box to your existing AWS account. The upgrade playbook stays the same either way. What matters is that you run it before August.&lt;/p&gt;

</description>
      <category>laravel13</category>
      <category>deployment</category>
      <category>zerodowntime</category>
      <category>upgrade</category>
    </item>
  </channel>
</rss>
