<?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>SOC 2 for a Laravel SaaS: What Infrastructure Evidence You'll Actually Need</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sat, 05 Sep 2026 11:03:05 +0000</pubDate>
      <link>https://dev.to/deploynix/soc-2-for-a-laravel-saas-what-infrastructure-evidence-youll-actually-need-1c8k</link>
      <guid>https://dev.to/deploynix/soc-2-for-a-laravel-saas-what-infrastructure-evidence-youll-actually-need-1c8k</guid>
      <description>&lt;p&gt;The deal was almost done. A mid-market company loved your Laravel product, the champion had budget approval, and the contract was sitting in legal. Then procurement sent over a security questionnaire with 240 questions, and question three asked: "Please attach your most recent SOC 2 Type II report." You don't have one. The deal doesn't die, but it stalls, and every week it sits in review is a week your competitor with a report in hand looks safer to buy from.&lt;/p&gt;

&lt;p&gt;If you're a small Laravel team, this is almost always how SOC 2 enters your life. Not through regulation, not through a security incident, but through a sales blocker. And the way it's usually explained, by auditors, for auditors, makes it sound far more mystical than it is. Strip away the vocabulary and SOC 2 is a structured way of answering one question: can you prove that you actually operate the security controls you claim to operate?&lt;/p&gt;

&lt;p&gt;The good news is that a well-run VPS-based Laravel stack already produces most of the evidence an auditor wants. Key-only SSH, firewall rules, git history, deploy logs, automated backups, monitoring alerts: these aren't compliance artifacts bolted on later, they're the exhaust of running production responsibly. The hard part isn't doing the work. It's capturing proof that you did it, continuously, for months.&lt;/p&gt;

&lt;p&gt;This post walks through SOC 2 the way we'd want it explained to us as engineers: when you actually need it, what evidence maps to what criteria, where teams get caught out (restore drills, offboarding), and what a realistic timeline looks like for a team of two to ten people.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- SOC 2 is an AICPA framework audited by licensed CPA firms; only the Security criterion is mandatory in scope. - Type I assesses control design at a point in time; Type II proves operation over a 3-12 month window. - Auditors want evidence, not claims: restore logs beat backup configs, access reviews beat access policies. - A hardened VPS Laravel stack already generates most required evidence if you capture it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Is SOC 2, and When Does a Laravel SaaS Actually Need One?
&lt;/h2&gt;

&lt;p&gt;SOC 2 is an attestation framework created by the &lt;a href="https://www.aicpa-cima.com" rel="noopener noreferrer"&gt;AICPA&lt;/a&gt;, the American Institute of Certified Public Accountants. It is not a certification you self-declare and not a government regulation. A licensed CPA firm examines your controls and issues a report stating whether those controls are designed properly and, for Type II, whether they operated effectively over an observation period.&lt;/p&gt;

&lt;p&gt;The framework is built on five Trust Services Criteria: Security, Availability, Confidentiality, Processing Integrity, and Privacy. Only Security is mandatory. The other four are optional scope you add based on what your customers care about. Most small SaaS companies start with Security alone or Security plus Availability, because those are the two that enterprise procurement teams actually ask about. Adding Privacy or Processing Integrity expands the audit surface considerably, and unless your buyers demand it, there's little reason to volunteer for it in year one.&lt;/p&gt;

&lt;p&gt;So when do you need it? Honestly: when sales tells you that you do. SOC 2 is an enterprise sales gate, not a legal requirement. If you sell to individual developers and small agencies, nobody will ever ask. The moment your average contract value climbs into the range where procurement departments get involved, the questionnaires start, and "we take security seriously" stops being an acceptable answer. If you're seeing security questionnaires on more than one deal per quarter, or a specific large deal is blocked on it, that's your signal. Before that point, your time is better spent on the underlying security work itself, which is why we'd point any Laravel team at &lt;a href="https://deploynix.io/blog/a-laravel-developers-production-security-checklist-2026-edition" rel="noopener noreferrer"&gt;a production security checklist&lt;/a&gt; long before an audit is on the calendar. Everything you fix now becomes evidence later.&lt;/p&gt;

&lt;p&gt;One more thing worth internalizing early: SOC 2 doesn't prescribe specific technology. There is no rule that says "use AWS" or "use this framework." The auditor evaluates whether your controls meet the criteria, not whether your stack matches some enterprise template. A Laravel monolith on a well-managed VPS can pass the same audit as a Kubernetes fleet, and often with a cleaner story, because there's less surface to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type I or Type II: Which Report Should You Get First?
&lt;/h2&gt;

&lt;p&gt;The two report types answer different questions, and the difference matters more than most first-timers realize.&lt;/p&gt;

&lt;p&gt;Type I&lt;/p&gt;

&lt;p&gt;Type II&lt;/p&gt;

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

&lt;p&gt;Control design at a single point in time&lt;/p&gt;

&lt;p&gt;Control operating effectiveness over a period&lt;/p&gt;

&lt;p&gt;Observation window&lt;/p&gt;

&lt;p&gt;None (snapshot date)&lt;/p&gt;

&lt;p&gt;Typically 3-12 months&lt;/p&gt;

&lt;p&gt;Auditor asks&lt;/p&gt;

&lt;p&gt;"Do these controls exist and make sense?"&lt;/p&gt;

&lt;p&gt;"Did these controls actually run, every time, all period?"&lt;/p&gt;

&lt;p&gt;Evidence burden&lt;/p&gt;

&lt;p&gt;Policies, configurations, one-time screenshots&lt;/p&gt;

&lt;p&gt;Continuous logs, tickets, reviews, drill records&lt;/p&gt;

&lt;p&gt;Time to obtain&lt;/p&gt;

&lt;p&gt;Weeks after readiness&lt;/p&gt;

&lt;p&gt;Observation window plus audit time&lt;/p&gt;

&lt;p&gt;Weight with enterprise buyers&lt;/p&gt;

&lt;p&gt;Moderate; often accepted as a bridge&lt;/p&gt;

&lt;p&gt;Strong; the default procurement ask&lt;/p&gt;

&lt;p&gt;Typical use&lt;/p&gt;

&lt;p&gt;First-year milestone while Type II window runs&lt;/p&gt;

&lt;p&gt;The report you renew annually&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type I&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Fast to obtain once your controls are designed and documented. Gives sales something real to attach to a questionnaire within weeks instead of months. Forces you to write down policies and close obvious gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams with an enterprise deal stalling right now who can't wait out an observation window, or teams using it as a forcing function to get their house in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Sophisticated buyers know a Type I only proves your controls existed on one day. Some procurement teams accept it as a bridge with a committed Type II date; others won't. Budget-wise you're paying for two audits in your first year if you do both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type II&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; The report buyers actually want. Proves your controls operated over a real window, which is a fundamentally stronger claim. Renewing it annually becomes routine once evidence collection is automated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that can start their observation window now and close enterprise deals in six to twelve months, or teams whose buyers have explicitly said Type I won't cut it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; The observation window is a minimum wait, not a formality. A control that lapsed for three weeks mid-window becomes an exception in your report. You need evidence discipline for the entire period, which is exactly where compliance automation platforms earn their fee.&lt;/p&gt;

&lt;p&gt;The pragmatic path we see most small teams take: start the readiness work immediately, open a Type II observation window as soon as controls are stable, and optionally grab a Type I along the way if a specific deal demands paper sooner.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Infrastructure Evidence Do Auditors Actually Ask For?
&lt;/h2&gt;

&lt;p&gt;Here's the part nobody explains well. Auditors don't inspect your servers. They request evidence: exports, logs, screenshots, tickets, and records that demonstrate each control operated. For a VPS-based Laravel stack, the mapping looks like this.&lt;/p&gt;

&lt;p&gt;Trust Services Criterion&lt;/p&gt;

&lt;p&gt;In scope?&lt;/p&gt;

&lt;p&gt;Concrete infrastructure evidence from a Laravel VPS stack&lt;/p&gt;

&lt;p&gt;Security&lt;/p&gt;

&lt;p&gt;Mandatory&lt;/p&gt;

&lt;p&gt;SSH key inventories, firewall rules, fail2ban logs, patching records, access review sign-offs, deploy logs, encrypted secrets configuration&lt;/p&gt;

&lt;p&gt;Availability&lt;/p&gt;

&lt;p&gt;Common add-on&lt;/p&gt;

&lt;p&gt;Uptime monitoring history, alert logs, backup schedules, restore drill records, incident post-mortems, rollback records&lt;/p&gt;

&lt;p&gt;Confidentiality&lt;/p&gt;

&lt;p&gt;Common add-on&lt;/p&gt;

&lt;p&gt;Encryption-at-rest evidence, TLS configuration, access scoping by role, data classification notes&lt;/p&gt;

&lt;p&gt;Processing Integrity&lt;/p&gt;

&lt;p&gt;Rare for small SaaS&lt;/p&gt;

&lt;p&gt;Job queue monitoring, data validation controls, reconciliation records&lt;/p&gt;

&lt;p&gt;Privacy&lt;/p&gt;

&lt;p&gt;Rare; overlaps GDPR&lt;/p&gt;

&lt;p&gt;Data retention schedules, deletion records, subprocessor list, server location documentation&lt;/p&gt;

&lt;p&gt;If you've already done GDPR work, some of this will feel familiar; our post on &lt;a href="https://deploynix.io/blog/gdpr-for-laravel-developers-data-storage-deletion-and-server-location-on-deploynix" rel="noopener noreferrer"&gt;GDPR for Laravel developers&lt;/a&gt; covers the data-location and deletion side, and that documentation is reusable if you ever add Privacy to scope. Now let's go domain by domain through what the Security and Availability criteria actually demand from your infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access Control: Who Can Touch Production, and How Do You Know?
&lt;/h3&gt;

&lt;p&gt;This is the first thing every auditor probes, and the first place small teams fail. The questions are simple. Who has SSH access to production? How is it granted? How is it revoked when someone leaves? Can two people be told apart in your logs?&lt;/p&gt;

&lt;p&gt;The engineering answers are ones you should already have: key-only SSH with password authentication disabled, no shared root credentials, one key per human, and a defined offboarding step that removes keys and platform access on the person's last day. The evidence answers are the exports that prove it. A dump of &lt;code&gt;authorized_keys&lt;/code&gt; entries mapped to named people. A record showing the departed contractor's key was removed within your stated window. Quarterly access review sign-offs where someone actually looked at the list and confirmed everyone on it still belongs there.&lt;/p&gt;

&lt;p&gt;Shared accounts are the classic finding here. If three engineers all log in as &lt;code&gt;deploy&lt;/code&gt; with the same key, your logs can't attribute actions to individuals, and the auditor will flag it. Individual keys plus a platform layer with per-user accounts and roles solves this cleanly. Offboarding is the second classic finding: teams that have never once removed an ex-employee's access, because nobody ever left before, discover mid-audit that they have no process and no record.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change Management: Can You Show How Code Reaches Production?
&lt;/h3&gt;

&lt;p&gt;Auditors want to see that changes to production are authorized, reviewed, and traceable. If you're using git with pull requests, you already run a change management system; you just haven't been calling it that.&lt;/p&gt;

&lt;p&gt;Your git history is the change record. PR reviews are the authorization step. Deploy logs, with timestamps, the deploying user, and the commit deployed, tie the change record to production. Rollback capability is your answer to "what happens when a change goes wrong," and a log showing you actually rolled back a bad deploy is better evidence than any policy paragraph about rollback procedures.&lt;/p&gt;

&lt;p&gt;What trips teams up: direct pushes to main with no review, hotfixes applied by SSHing in and editing files on the server (which bypasses the entire change record), and deploys with no log of who triggered them. The fix isn't process theater. It's making the pipeline the only path to production and letting the pipeline's own logs be your evidence.&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;# A surprising amount of change-management evidence is one command away&lt;/span&gt;
git log &lt;span class="nt"&gt;--merges&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01"&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%h %an %ad %s"&lt;/span&gt; &lt;span class="nt"&gt;--date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;short

&lt;span class="c"&gt;# Show that branch protection forces review (GitHub CLI)&lt;/span&gt;
gh api repos/OWNER/REPO/branches/main/protection &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.required_pull_request_reviews'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Vulnerability Management: How Do Patches Actually Happen?
&lt;/h3&gt;

&lt;p&gt;The control here is a patching routine that runs whether or not anyone remembers it. On Ubuntu, that means unattended security upgrades, and the evidence is the configuration plus the logs showing it ran.&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;# Confirm unattended upgrades are enabled&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/apt/apt.conf.d/20auto-upgrades
&lt;span class="c"&gt;# APT::Periodic::Update-Package-Lists "1";&lt;/span&gt;
&lt;span class="c"&gt;# APT::Periodic::Unattended-Upgrade "1";&lt;/span&gt;

&lt;span class="c"&gt;# Evidence that it has actually been running&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Packages that will be upgraded"&lt;/span&gt; /var/log/unattended-upgrades/unattended-upgrades.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the application side, &lt;code&gt;composer audit&lt;/code&gt; checks your dependency tree against known advisories, and running it in CI on every build turns it from a good habit into a logged, recurring control:&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;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; audit-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the outputs. A folder of dated audit results showing you scanned weekly and remediated findings is exactly the kind of evidence that makes a Type II observation window painless. Auditors also like to see that findings go somewhere: a ticket that says "bumped guzzlehttp/psr7 for advisory X, deployed on this date" closes the loop from detection to fix to production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backup and Recovery: Auditors Want Restore Evidence, Not Backup Evidence
&lt;/h3&gt;

&lt;p&gt;Read that heading twice, because this is the single most common gap we see. Teams show auditors a backup schedule and a bucket full of dumps and consider the control satisfied. The auditor's next question: "Show me the last time you restored one."&lt;/p&gt;

&lt;p&gt;A backup you've never restored is a hypothesis. The control auditors actually care about is recoverability, and the evidence is a restore drill record: a dated log showing someone pulled a backup, restored it to a scratch database, verified row counts or ran the test suite against it, and recorded how long it took. Do this quarterly, write down the date, duration, and outcome each time, and you've turned twenty minutes of work into some of the strongest Availability evidence in your report.&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;# A minimal quarterly restore drill, logged&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;s3://backups/db/latest.sql.gz ./drill/ &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; &lt;span class="nv"&gt;$S3_ENDPOINT&lt;/span&gt;
&lt;span class="nb"&gt;gunzip &lt;/span&gt;drill/latest.sql.gz
mysql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 restore_drill &amp;lt; drill/latest.sql
mysql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 restore_drill &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SELECT COUNT(*) FROM users; SELECT MAX(created_at) FROM orders;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; restore drill OK, 14m22s, verified users+orders tables"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; drills.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retention matters too: state a retention policy (say, 30 daily backups) and be able to show the bucket actually reflects it. If you want the full setup, our guide on &lt;a href="https://deploynix.io/blog/automated-database-backups-set-it-forget-it-sleep-well" rel="noopener noreferrer"&gt;automated database backups&lt;/a&gt; covers schedules, retention, and off-server storage; the drill habit is what upgrades that setup from good practice to audit evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring and Incident Response: What Happens When Things Break?
&lt;/h3&gt;

&lt;p&gt;The Availability criterion wants proof that you detect problems and respond to them in a structured way. Three artifacts cover most of it: monitoring with alert history, a written incident response playbook, and post-mortems for incidents that actually happened.&lt;/p&gt;

&lt;p&gt;Alert history is the underrated one. A log of every alert fired over the observation window, with timestamps showing when it fired and when it resolved, demonstrates that your monitoring is real and that someone responds. A playbook doesn't need to be long; ours fits the shape we described in &lt;a href="https://deploynix.io/blog/your-server-is-down-a-step-by-step-incident-response-playbook-for-deploynix-users" rel="noopener noreferrer"&gt;the incident response playbook for server outages&lt;/a&gt;: detect, triage, communicate, fix, review. What matters to an auditor is that it exists, names roles, and that your post-mortems reference it.&lt;/p&gt;

&lt;p&gt;And here's a counterintuitive tip from the trenches: incidents help your audit. A team with two documented incidents, each with a post-mortem and a follow-up fix, tells a stronger story than a team claiming a spotless record with no evidence their process has ever been exercised. Don't hide your outages from the auditor. Show them the paper trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Encryption: In Transit, at Rest, and in Your Secrets
&lt;/h3&gt;

&lt;p&gt;The expectations here are unambiguous. TLS on every public endpoint, with certificates that renew automatically. Secrets not sitting in plaintext in a repo or a shared doc. Backups encrypted before they land in object storage. Provider API tokens and SSH private keys encrypted at rest wherever they're stored.&lt;/p&gt;

&lt;p&gt;For a Laravel app, the secrets story deserves specific attention because &lt;code&gt;.env&lt;/code&gt; files are the soft underbelly of most stacks. Laravel's encrypted environment files (&lt;code&gt;php artisan env:encrypt&lt;/code&gt;) and a defined process for who can read production secrets go a long way; we wrote up the full approach in &lt;a href="https://deploynix.io/blog/secrets-management-for-laravel-env-encrypted-config-and-deploynix" rel="noopener noreferrer"&gt;secrets management for Laravel&lt;/a&gt;. Evidence-wise, the auditor wants your TLS configuration (an SSL Labs result or certificate inventory works), your encryption-at-rest description for the database and backups, and your answer to "who can see production credentials, and how is that access logged?"&lt;/p&gt;

&lt;p&gt;The firewall and intrusion-prevention layer rounds out the Security picture, and the evidence is literally command output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw status verbose
&lt;span class="c"&gt;# Status: active&lt;/span&gt;
&lt;span class="c"&gt;# Default: deny (incoming), allow (outgoing)&lt;/span&gt;
&lt;span class="c"&gt;# 22/tcp  ALLOW IN  Anywhere&lt;/span&gt;
&lt;span class="c"&gt;# 80,443/tcp  ALLOW IN  Anywhere&lt;/span&gt;

fail2ban-client status sshd
&lt;span class="c"&gt;# |- Currently banned: 3&lt;/span&gt;
&lt;span class="c"&gt;# `- Total banned: 412&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Total banned&lt;/code&gt; counter, captured monthly, is a tidy little proof that the control isn't just configured but actively working.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gap Between "We Do This" and "We Can Prove We Do This"
&lt;/h2&gt;

&lt;p&gt;Every domain above has the same failure mode. The engineering is done, the control operates, and yet the audit is painful, because nothing was captured. Closing that gap is mostly about building three habits before your observation window opens, not after.&lt;/p&gt;

&lt;p&gt;First, prefer pulled evidence over posed evidence. A screenshot of a firewall config proves the config existed the moment you took the screenshot. A log exported from an API proves the control operated over time, can't be quietly staged, and doesn't require a human to remember anything. Wherever your infrastructure can emit evidence programmatically, wire that up; save screenshots for the handful of things that have no export. This is also the core value proposition of compliance automation platforms like Vanta, Drata, and Secureframe: they connect to your systems and collect evidence continuously so the observation window documents itself.&lt;/p&gt;

&lt;p&gt;Second, run quarterly access reviews and write them down. Fifteen minutes, four times a year: list everyone with production access, SSH keys, database credentials, and admin roles in your platform tooling; confirm each person still needs it; remove what's stale; record who reviewed and when. This single ritual satisfies a surprising number of audit requests, and it's the ritual most teams skip because "we're only four people, we know who has access." The auditor doesn't know that, and in a year you won't remember either.&lt;/p&gt;

&lt;p&gt;Third, keep policies short and true. You will need written policies: information security, access control, change management, incident response, business continuity. The temptation is to download a 40-page template and adopt it wholesale. Resist it. Auditors test whether you follow your policies, so every aspirational sentence you didn't need becomes a control you can fail. A two-page policy that accurately describes what your team actually does will serve you better than an impressive binder describing a company you aren't. Write down reality, then improve reality, then update the document.&lt;/p&gt;

&lt;p&gt;If you want a structured way to find the gaps before an auditor does, a self-run security audit of your own servers is the closest thing to a dress rehearsal: every item you verify is an item you now know how to evidence, and every item you can't verify just told you where your first exception would come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Deploynix Features Double as Audit Evidence
&lt;/h2&gt;

&lt;p&gt;We built Deploynix as a provisioning and deployment platform, not a compliance product. But somewhere around the third customer who asked us for evidence exports, we realized that a platform which manages your servers is, almost by accident, an evidence collection system. Here's the honest mapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access control.&lt;/strong&gt; Deploynix teams have organization roles, so "who can deploy to production" is a defined, reviewable list rather than tribal knowledge, and the quarterly access review becomes reading one screen. Servers are provisioned with key-only SSH from day one, so there's no password-auth finding waiting to happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit trail.&lt;/strong&gt; Every server action taken through the platform, provisioning, deploys, config changes, is recorded in server logs with the acting user and timestamp. That's the attributable, per-person action history the access control and change management criteria both lean on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change management.&lt;/strong&gt; Deploys are logged with commit, user, and time, which turns each release into a change record that reconciles against your git history. Zero-downtime deploys with rollback give you the "what happens when a change is bad" answer, and a rollback that actually happened is sitting right there in the deploy log as evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vulnerability management.&lt;/strong&gt; Unattended security upgrades are enabled by default on provisioned servers, alongside UFW in deny-by-default mode and fail2ban. The hardened baseline isn't something you configure for the audit; it's the starting state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backup and recovery.&lt;/strong&gt; Automated database backups run on schedule to S3-compatible storage with retention, giving you the schedule and retention evidence directly. The restore drills are still on you, and they should be; no platform can honestly automate away the act of verifying your own recoverability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring.&lt;/strong&gt; Server monitoring keeps alert history with 30-day metric retention, so "show me your alerting worked during the window" is an export rather than an archaeology project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encryption.&lt;/strong&gt; Provider credentials and SSH private keys are encrypted at rest in the platform, which is one line in your confidentiality narrative you don't have to build yourself.&lt;/p&gt;

&lt;p&gt;Everything above is also reachable through the API, which matters more than it sounds: when your auditor or your compliance automation platform asks for evidence, a scripted export beats an afternoon of screenshots every single time. None of this makes you SOC 2 compliant on its own; no tool does. It means the infrastructure layer of your evidence list is largely handled, and your remaining work concentrates on policies, reviews, drills, and the application layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Long Does It Take, and What Does It Cost?
&lt;/h2&gt;

&lt;p&gt;Plan in months, not weeks, and plan for two cost centers: the auditor and the tooling.&lt;/p&gt;

&lt;p&gt;A realistic sequence for a small Laravel team looks like this. One to two months of readiness work: hardening gaps, policies, access reviews started, evidence collection wired up. Then the Type II observation window opens, typically three months for a first report (buyers sometimes discount a three-month window; six or twelve carries more weight on renewal). During the window, your job is simply to keep the controls running and the evidence flowing. After the window closes, the CPA firm performs fieldwork and writes the report, which adds several more weeks. End to end, six to nine months from "we should do this" to a Type II PDF in the sales team's hands is a sane expectation. Teams that need paper sooner slot a Type I in after readiness.&lt;/p&gt;

&lt;p&gt;On cost, we'll stay general rather than quote numbers that vary widely by firm and scope: you're paying a licensed CPA firm for the audit itself, usually annually, plus a subscription if you use a compliance automation platform. Together they're a meaningful line item for a small company, which is exactly why the trigger should be real enterprise demand rather than ambient anxiety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance automation platform (Vanta, Drata, Secureframe)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Continuous, API-pulled evidence collection; policy templates; auditor marketplaces; dramatically less manual gathering during the window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams going for Type II who want the observation window to document itself, and teams without a dedicated compliance person, which is every small team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; A real recurring cost, and the templates still need editing down to your reality. The platform collects evidence; it doesn't operate your controls for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual evidence collection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; No subscription cost. Forces deep familiarity with your own controls. Viable for a Type I with narrow scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams testing the waters with a Type I, or teams whose auditor provides a simple evidence request list and whose stack is small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; For a Type II window, manual collection means months of remembering to capture things on schedule. One forgotten month is an exception in your report. Most teams that start manual switch before renewal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Do Auditors Not Care About?
&lt;/h2&gt;

&lt;p&gt;Knowing what's out of scope saves real anxiety, so let's clear a few things up.&lt;/p&gt;

&lt;p&gt;They don't care that you use Laravel. Framework choice is invisible to the criteria. Nobody loses points for PHP, and nobody gains points for Rust. The auditor cares whether changes are reviewed, dependencies are scanned, and secrets are protected, in whatever stack you run.&lt;/p&gt;

&lt;p&gt;They don't care which cloud or VPS provider you use. DigitalOcean, Hetzner, AWS, a European provider chosen for data residency: all fine. What they'll ask for is your subprocessor list and your providers' own compliance reports (major infrastructure providers publish these), which slot into your evidence as inherited controls.&lt;/p&gt;

&lt;p&gt;They don't care that your team is small. There's no minimum headcount for SOC 2. Small teams do face specific friction, like separation of duties when the person writing code is also the person approving deploys, but auditors handle this constantly and there are accepted compensating controls, like logged self-review with after-the-fact spot checks.&lt;/p&gt;

&lt;p&gt;And they don't care about perfection. A report can include exceptions and still be valuable; a noted exception with a documented remediation reads as a functioning program, not a failing one. The report is a narrative of how you operate, not a pass/fail exam with a trophy at the end.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do we need SOC 2 if we're a two-person Laravel startup?
&lt;/h3&gt;

&lt;p&gt;Not until your buyers ask for it. SOC 2 is a sales instrument, not a legal obligation. Do the underlying security work now, start keeping evidence habits early because they're cheap, and pull the trigger on an audit when enterprise questionnaires start arriving or a specific deal is gated on a report.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can we pass SOC 2 on a VPS, or do we need a big cloud provider?
&lt;/h3&gt;

&lt;p&gt;You can absolutely pass on a VPS. The criteria are provider-agnostic; auditors evaluate your controls, not your vendor's logo. You'll list your infrastructure provider as a subprocessor and reference their compliance documentation, then evidence your own layer: access, patching, backups, monitoring, and change management.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type I first or straight to Type II?
&lt;/h3&gt;

&lt;p&gt;If no deal is burning, go straight for Type II: start the readiness work, open a three-month observation window, and you'll have the stronger report in roughly two quarters. If a deal is stalled today, a Type I plus a committed Type II date is a common and often accepted bridge.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the most commonly missing piece of evidence?
&lt;/h3&gt;

&lt;p&gt;Restore drills. Nearly every team can show backups exist; far fewer can show a dated record of restoring one and verifying the data. Quarterly drills with a one-line log entry each are the cheapest audit win available. Second place goes to offboarding records and quarterly access reviews.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does using Deploynix make us SOC 2 compliant?
&lt;/h3&gt;

&lt;p&gt;No, and be wary of any tool that claims otherwise. Deploynix gives you a hardened default baseline and exportable evidence for the infrastructure layer: action logs, deploy history, backup schedules, alert history. Policies, access reviews, drills, and the audit itself remain your work with a CPA firm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start This Week
&lt;/h2&gt;

&lt;p&gt;You don't start SOC 2 by calling an auditor. You start it by making your infrastructure provably boring: attributable access, logged changes, automatic patching, tested restores, and monitoring with a paper trail. Everything in this post compounds; evidence habits started this quarter are observation-window gold next year, and every gap you close is one less finding when a CPA firm finally looks.&lt;/p&gt;

&lt;p&gt;If you do one thing this week, make it a dry run: work through &lt;a href="https://deploynix.io/blog/hardening-your-deploynix-server-a-step-by-step-security-audit" rel="noopener noreferrer"&gt;a step-by-step security audit of your server&lt;/a&gt;, and for every control you verify, ask the auditor's question instead of the engineer's one. Not "does this work?" but "could I prove this worked in March?" The distance between those two answers is your entire SOC 2 project, measured honestly, before anyone bills you for the measuring.&lt;/p&gt;

</description>
      <category>soc2</category>
      <category>compliance</category>
      <category>security</category>
      <category>saas</category>
    </item>
    <item>
      <title>DigitalOcean vs. Vultr vs. Linode for Laravel: A Price and Performance Breakdown</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 03 Sep 2026 16:46:53 +0000</pubDate>
      <link>https://dev.to/deploynix/digitalocean-vs-vultr-vs-linode-for-laravel-a-price-and-performance-breakdown-5190</link>
      <guid>https://dev.to/deploynix/digitalocean-vs-vultr-vs-linode-for-laravel-a-price-and-performance-breakdown-5190</guid>
      <description>&lt;p&gt;Ask around any Laravel community where people host and you'll hear the same three names, with DigitalOcean usually first — a split that has less to do with performance than with marketing budgets and muscle memory. All three providers sell the same basic product: a KVM virtual machine with NVMe or SSD storage, a public IP, and an API. The differences that actually matter for a Laravel app hide in the details, and in 2026 those details are no longer on the pricing page headline.&lt;/p&gt;

&lt;p&gt;Here's the thing we've noticed provisioning servers on all three providers week in, week out: the entry prices have converged almost completely. Vultr's cheapest Cloud Compute plan costs $4 per month for 0.5 GB of RAM and 10 GB of NVMe storage, DigitalOcean's basic Droplet also starts around $4, and Linode's minimum sits near $5 but ships with a full 1 GB of RAM, the best price-to-memory ratio at the bottom tier (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte comparison&lt;/a&gt;, 2026). A dollar here or there won't decide anything. Egress fees, snapshot pricing, backup surcharges, and managed database costs will.&lt;/p&gt;

&lt;p&gt;So this isn't a generic VPS review. It's a Laravel-specific breakdown: what your app actually demands from a virtual machine, which provider wins at each budget, where the hidden costs live, and a real verdict for three common situations. If you want the wider decision framework first, our &lt;a href="https://deploynix.io/blog/the-laravel-developers-guide-to-choosing-a-cloud-provider-in-2026" rel="noopener noreferrer"&gt;guide to choosing a cloud provider in 2026&lt;/a&gt; covers the full field. Here, we're putting just these three head to head.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does a Laravel App Actually Need From a VPS?
&lt;/h2&gt;

&lt;p&gt;A Laravel app has a specific resource profile, and it's not the one most VPS marketing targets. Independent testing at &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt; shows all three providers trading blows on synthetic CPU scores, but Laravel cares about four things in a particular order: single-core speed, disk IOPS, RAM headroom, and egress allowance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-core speed for PHP-FPM.&lt;/strong&gt; PHP executes one request on one core. A request that takes 80ms on a fast core takes 140ms on a slow one, and no amount of extra cores fixes that for an individual user. This is why Vultr's High Frequency line and DigitalOcean's Premium (Intel/AMD) Droplets exist: same specs on paper, noticeably faster clock speeds and NVMe under load. When you benchmark, run a single-threaded test, not just a multi-core score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NVMe IOPS for MySQL.&lt;/strong&gt; Your database is the usual bottleneck in a Laravel app, and MySQL under write load is brutally sensitive to storage latency. All three providers now offer NVMe on their current-generation plans, but older "regular" tiers on Vultr and standard Droplets on DigitalOcean still use plain SSD. A quick way to check what you actually got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fio &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;randwrite &lt;span class="nt"&gt;--ioengine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;libaio &lt;span class="nt"&gt;--iodepth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;16 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--rw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;randwrite &lt;span class="nt"&gt;--bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4k &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1G &lt;span class="nt"&gt;--numjobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--direct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="nt"&gt;--group_reporting&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a rough expectation rather than a hard rule: tens of thousands of random write IOPS is comfortable for a small production MySQL instance, while results in the low thousands will make themselves felt in queue processing and migrations. Compare your own numbers across the tiers you're considering — the relative gap matters more than any absolute threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAM headroom.&lt;/strong&gt; PHP-FPM workers, OPcache, MySQL's buffer pool, and Redis or Valkey all compete for memory. On a 1 GB box you're rationing. On 4 GB you can give MySQL a real buffer pool and still run 10 to 15 FPM workers. This is why the 4 GB tier, not the entry tier, is where most production Laravel apps end up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Egress for asset-heavy apps.&lt;/strong&gt; If you serve images, video, or large downloads directly from the server instead of a CDN or object storage, transfer allowance stops being a footnote. The three providers differ here more than anywhere else, and we'll put numbers on it below.&lt;/p&gt;

&lt;p&gt;Snapshots deserve an honorable mention too. Taking a snapshot before a risky deploy or a major version upgrade is cheap insurance, but only if snapshot pricing doesn't punish you for using it. More on that in the hidden costs section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entry Tier: Who Wins Under $6 for Side Projects?
&lt;/h2&gt;

&lt;p&gt;At the bottom of the price list, all three providers are within a dollar of each other, but the specs are not equal. At the time of writing, here's what your first $4 to $5 buys (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte comparison&lt;/a&gt;, 2026):&lt;/p&gt;

&lt;p&gt;ProviderPlanPrice/movCPURAMStorageTransferDigitalOceanBasic Droplet~$41512 MB10 GB SSD500 GBVultrCloud Compute$41512 MB10 GB NVMe~1 TBLinodeNanode 1 GB~$511 GB25 GB SSD1 TB&lt;/p&gt;

&lt;p&gt;Read that RAM column twice. Linode charges roughly a dollar more and hands you double the memory and more than double the storage. For a Laravel app, 512 MB is genuinely tight: after the OS, Nginx, PHP-FPM, and a minimal MySQL instance, you're flirting with the OOM killer every time Composer runs. We've watched &lt;code&gt;composer install&lt;/code&gt; fail on 512 MB boxes purely because MySQL and the install process couldn't coexist without swap.&lt;/p&gt;

&lt;p&gt;Our honest guidance for this tier: it's for side projects, staging boxes, and demos, and Linode's Nanode is the best Laravel fit of the three because 1 GB of RAM is the practical floor for running the full stack on one machine. Vultr's $4 plan wins if you offload the database elsewhere, since NVMe makes it snappy for what it is. DigitalOcean's $4 Droplet is the weakest deal on paper but comes attached to the best documentation ecosystem, which matters more than specs when it's your first server.&lt;/p&gt;

&lt;p&gt;Don't underestimate what these small boxes can do, though. We load tested this exact class of machine in &lt;a href="https://deploynix.io/blog/how-much-traffic-can-a-5-server-handle-load-testing-laravel-on-deploynix" rel="noopener noreferrer"&gt;how much traffic a $5 server can handle&lt;/a&gt;, and a tuned Laravel app with OPcache and proper caching served hundreds of requests per second before falling over. The entry tier isn't a toy. It's just not where you want your database living long-term.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 GB / 2 vCPU Tier: Where Most Production Laravel Apps Live
&lt;/h2&gt;

&lt;p&gt;Ask us where the median production Laravel app runs and the answer is boring: one server, 4 GB RAM, 2 vCPUs, everything on it. That's enough for MySQL with a 1 GB buffer pool, Valkey or Redis for cache and sessions, a dozen PHP-FPM workers, a queue worker or two, and the scheduler. At the time of writing, the class shakes out like this:&lt;/p&gt;

&lt;p&gt;ProviderPlanPrice/movCPURAMStorageTransferDigitalOceanBasic Droplet (Regular)$2424 GB80 GB SSD4 TBDigitalOceanBasic Droplet (Premium NVMe)$2824 GB80 GB NVMe4 TBVultrCloud Compute (Regular)$2024 GB80 GB SSD3 TBVultrHigh Frequency$2424 GB128 GB NVMe3 TBLinodeLinode 4 GB (Shared)$2424 GB80 GB SSD4 TB&lt;/p&gt;

&lt;p&gt;Three observations from running Laravel workloads on all five of those plans.&lt;/p&gt;

&lt;p&gt;First, Vultr is the price aggressor. Twenty dollars for the regular plan undercuts everyone, and the High Frequency plan at $24 gives you 128 GB of NVMe, 60% more storage than the equivalent Droplet, at the same price. If raw specs per dollar decide your purchase, Vultr takes this tier, which matches its reputation for the most aggressive pricing in the segment (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes comparison&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Second, DigitalOcean's Premium NVMe surcharge is worth paying for Laravel. The $4 difference between regular and Premium buys newer CPUs and NVMe storage, and in our experience that translates to visibly lower p95 response times under database load. If you're on DigitalOcean, don't provision the regular plan for production in 2026.&lt;/p&gt;

&lt;p&gt;Third, Linode's 4 TB transfer at $24 quietly matters. It matches DigitalOcean and beats Vultr by a terabyte, and Linode has historically been generous when pooling transfer across servers on the same account. For an app serving user uploads or media without a CDN in front, that extra headroom is real money.&lt;/p&gt;

&lt;p&gt;Performance-wise, independent trials at &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt; show the three providers within a band of each other on web workloads, with rankings shifting between test runs and regions. Nobody wins this tier on performance alone. The differentiation is price (Vultr), transfer and support (Linode), and ecosystem (DigitalOcean).&lt;/p&gt;

&lt;h2&gt;
  
  
  DigitalOcean: The Ecosystem Default
&lt;/h2&gt;

&lt;p&gt;DigitalOcean is where most Laravel developers start, and there's a defensible reason: no provider has invested more in documentation. The community tutorial library covers nearly every Nginx, MySQL, and PHP configuration question you'll ever have, and it's usually the first search result when something breaks at 2 a.m. Industry comparisons consistently rank DigitalOcean first on ecosystem, docs, and marketplace breadth among these three (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Beyond Droplets, the product surface is the widest here: managed MySQL and Postgres, Spaces object storage with a built-in CDN, load balancers, VPC networking, a container registry, and a marketplace of one-click images. For a Laravel team, that means your app server, your S3-compatible backup target, and your managed database can all live in one account with private networking between them.&lt;/p&gt;

&lt;p&gt;The API is the best of the three in practice. It's consistent, well-documented, versioned, and wrapped by mature client libraries in every language that matters, including solid PHP SDKs. Terraform and Packer support is first-class. If you automate infrastructure, DigitalOcean is the least likely to surprise you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Best documentation and community content in the industry; broadest product ecosystem (managed databases, Spaces, load balancers, VPC); most polished API and tooling; Premium NVMe Droplets are genuinely fast for PHP workloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams and agencies that value predictability and ecosystem over squeezing the last dollar; developers who want managed MySQL and object storage from the same vendor; anyone who leans on community tutorials when debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Rarely the cheapest option at any tier; the regular (non-Premium) Droplets use older hardware you should avoid for production; 500 GB transfer on the entry Droplet is the stingiest of the three; support responsiveness on lower tiers is ticket-based and can be slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vultr: The Price and Regions Aggressor
&lt;/h2&gt;

&lt;p&gt;Vultr's pitch is simple: more locations and lower prices than anyone else in this weight class. At the time of writing it operates 30+ regions, the most of the three by a wide margin, with locations the others don't touch, including São Paulo, Johannesburg, Mumbai, Seoul, and multiple Australian cities (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). If your users are in a geography DigitalOcean and Linode treat as an afterthought, Vultr may be the only one of the three with a data center nearby.&lt;/p&gt;

&lt;p&gt;The pricing aggression is real, not just marketing. The $4 entry plan with NVMe, the $20 regular 4 GB plan, and the High Frequency line all undercut or out-spec the equivalent products next door. Vultr also sells niche products the others don't at this price point: bare metal servers, GPU instances, and dedicated-CPU plans with fine-grained sizing.&lt;/p&gt;

&lt;p&gt;Where does Laravel fit? The High Frequency plans are the sweet spot. Fast clocks help PHP-FPM directly, and the oversized NVMe allocation gives MySQL room to breathe. We've found Vultr instances perform very close to DigitalOcean Premium Droplets at a slightly lower price, with the caveat that performance consistency varies more by region than it does on the other two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Most aggressive pricing of the three at nearly every tier; most regions by far, with real APAC, South America, and Africa coverage; High Frequency NVMe plans are excellent for PHP and MySQL; broad product range including bare metal and GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Cost-sensitive builders who compare spec sheets; apps whose users live outside the US/EU corridor; developers who offload the database and want the cheapest fast app server possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Ecosystem and documentation depth trail DigitalOcean noticeably; performance consistency varies more between regions; snapshots and some features that were once free are now billed; support reputation is adequate rather than praised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linode (Akamai): The Steady Operator
&lt;/h2&gt;

&lt;p&gt;Linode is the oldest of the three, and since the Akamai acquisition it's become the "boring in a good way" option. Comparisons consistently highlight two things: generous transfer allowances and a long-standing reputation for reachable, human support (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). Both matter more in month twelve of running an app than they do on day one.&lt;/p&gt;

&lt;p&gt;The entry tier is where Linode's value is most obvious. The ~$5 Nanode's 1 GB of RAM makes it the best price-to-memory deal at the bottom of the market (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte&lt;/a&gt;, &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt;), and 1 GB is precisely the line where a full single-server Laravel stack becomes comfortable instead of precarious. For staging environments and client demo servers, we reach for Nanodes constantly.&lt;/p&gt;

&lt;p&gt;The Akamai connection adds something the other two can't match: the backbone. Linode traffic rides Akamai's network, which shows up as consistently good routing and peering, particularly for globally distributed users hitting a single origin. Akamai has also been expanding Linode's region list into its edge footprint, closing what used to be Linode's biggest gap against Vultr.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Best RAM per dollar at the entry tier; generous 4 TB transfer at the 4 GB tier with account-level pooling; support reputation is the best of the three; Akamai's network and backbone; predictable, stable pricing history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Hobby projects and staging servers where 1 GB of RAM at $5 is the whole decision; transfer-heavy apps serving media from the origin; teams that want to talk to a human when something breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Managed database offering has had a bumpy history (paused, then relaunched under Akamai), so verify current availability in your region before depending on it; the product ecosystem is narrower than DigitalOcean's; the dashboard and marketplace feel a generation behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Do the Hidden Costs Hide?
&lt;/h2&gt;

&lt;p&gt;Here's the 2026 nuance that changed how we advise people: the headline compute price stopped being the whole story. Egress overages, snapshot storage, backup surcharges, and managed add-ons now decide the real monthly bill more often than the base plan does (&lt;a href="https://apicalculators.com/blog/cloud-vps-cost-comparison-2026" rel="noopener noreferrer"&gt;apicalculators&lt;/a&gt;, 2026). Two identical $24 servers can produce $24 and $47 invoices depending on how you use snapshots and where your traffic goes.&lt;/p&gt;

&lt;p&gt;At the time of writing, the side costs stack up like this:&lt;/p&gt;

&lt;p&gt;Cost itemDigitalOceanVultrLinodeEgress overage (per GB)$0.01$0.01$0.005Snapshots$0.06/GB/mo$0.05/GB/moNo standalone snapshots (use Images at ~$0.10/GB/mo)Automated backups+20% of plan (weekly), +30% (daily)+20% of planFlat fee per plan size (e.g., ~$5/mo on a 4 GB Linode)Included transfer (4 GB tier)4 TB3 TB4 TB (pooled)&lt;/p&gt;

&lt;p&gt;A worked example makes it concrete. Take a $24 server, weekly provider backups, one 60 GB snapshot kept as a pre-upgrade restore point, and 1 TB of overage because your app serves user-uploaded video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DigitalOcean: $24 + $4.80 (backups) + $3.60 (snapshot) + $10.00 (overage) = $42.40
Vultr:        $24 + $4.80 (backups) + $3.00 (snapshot) + $10.00 (overage) = $41.80
Linode:       $24 + $5.00 (backups) + $6.00 (image)    + $5.00  (overage) = $40.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap between providers is small. The gap between the sticker price and the real bill is not: that's a 67% to 77% markup over the base plan in every case. Three practical mitigations, whichever provider you pick: put a CDN in front of anything asset-heavy so overage never triggers, delete snapshots after the upgrade they were insuring succeeds, and consider replacing the provider's percentage-based backup product with your own dump-and-ship pipeline to S3-compatible storage, which typically costs $1 to $2 per month for the same data.&lt;/p&gt;

&lt;p&gt;One more subtle difference: Linode's per-GB egress overage is half the price of the other two. For origin-served media at scale, that halves your most unpredictable line item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed Database or a Second VPS?
&lt;/h2&gt;

&lt;p&gt;At some point every growing Laravel app faces the same fork: move MySQL to the provider's managed database product, or self-host it on a second VPS. The price gap is bigger than most people expect.&lt;/p&gt;

&lt;p&gt;At the time of writing, DigitalOcean's managed MySQL starts around $15 per month for a 1 GB single-node cluster, with high-availability configurations (a primary plus standby) starting north of $50. Vultr and Linode's managed database offerings land in a similar band, with entry plans in the $15 to $35 range depending on engine and region, though Linode's product has been through a pause-and-relaunch cycle under Akamai and deserves a current-availability check before you commit.&lt;/p&gt;

&lt;p&gt;Compare that to self-hosting: a dedicated $6 to $12 VPS running only MySQL, with the buffer pool tuned to the box, private networking to your app server, and nightly dumps shipped off-site. Roughly half the cost of the cheapest managed tier, and a quarter of the HA tier.&lt;/p&gt;

&lt;p&gt;So what does the managed premium buy? Automated failover, point-in-time recovery, patching, and one less thing to page you. Those are real. But be honest about what the entry-level managed plans actually include: the $15 single-node plans have no standby, so you're paying the premium without getting the failover. In our experience, the managed option earns its cost at the HA tier and above, when downtime is measured in lost revenue. Below that, a second VPS with automated backups covers the same risk for less, and it's the pattern we see most $10-to-$50-per-month Laravel apps settle on. Teams doing &lt;a href="https://deploynix.io/blog/cloud-repatriation-for-laravel-from-aws-to-a-10-vps" rel="noopener noreferrer"&gt;cloud repatriation from AWS to a $10 VPS&lt;/a&gt; make exactly this trade deliberately, swapping RDS convenience for a bill that's an order of magnitude smaller.&lt;/p&gt;

&lt;p&gt;The Laravel-specific footnote: whichever route you choose, keep the database in the same region and on the same private network as the app server. A 30ms round trip between app and database turns a 12-query page into a 360ms page before PHP does any work at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regions and Latency: Who Covers Your Users?
&lt;/h2&gt;

&lt;p&gt;Region choice is a Laravel performance decision disguised as a dropdown menu. TTFB for an uncached page is dominated by the round trip to your origin, so put the server near your users, not near you.&lt;/p&gt;

&lt;p&gt;The coverage differences at the time of writing are stark. Vultr fields 30+ locations, the widest map of the three, with meaningful presence in South America (São Paulo), Africa (Johannesburg), India (Mumbai, Delhi region), Korea, Japan, and Australia (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). DigitalOcean runs a tighter list of around 15 regions: strong US and EU coverage, plus Singapore, Bangalore, Sydney, and Toronto, but nothing in South America or Africa. Linode's classic list (US, EU, Singapore, Tokyo, Mumbai, Sydney) has been expanding under Akamai into new metros riding the Akamai backbone, and routing quality on that backbone is a quiet advantage even where the region count is lower.&lt;/p&gt;

&lt;p&gt;Practical translations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US or EU audience: all three cover you well; pick on price and ecosystem, not geography.&lt;/li&gt;
&lt;li&gt;APAC audience: Vultr's breadth wins (Seoul, Osaka, multiple Australian cities); Linode's Tokyo and Singapore locations are solid seconds with excellent peering.&lt;/li&gt;
&lt;li&gt;South America or Africa: Vultr is the only one of the three with in-region compute; the others route you to US East or EU, adding 100 to 200ms per round trip.&lt;/li&gt;
&lt;li&gt;Latency-sensitive split audiences: consider two small servers in two regions behind GeoDNS rather than one big server in the middle. Two Nanodes cost $10.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if your audience is European and budget is the top priority, we'd be doing you a disservice not to mention that &lt;a href="https://deploynix.io/blog/hetzner-deploynix-the-best-price-to-performance-combo-for-laravel-in-europe" rel="noopener noreferrer"&gt;Hetzner undercuts all three on price-to-performance in the EU&lt;/a&gt;, often by 2x or more at the same price point. That's a separate post because it's a separate conversation, but it's the honest asterisk on this entire comparison for EU-first apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does API Quality Actually Matter?
&lt;/h2&gt;

&lt;p&gt;If you ever provision more than one server, yes. The API is the difference between infrastructure you can rebuild from a script and infrastructure that exists because someone clicked buttons eighteen months ago.&lt;/p&gt;

&lt;p&gt;All three providers expose full REST APIs and official CLIs, and all three are fully supported by Terraform. Creating a server is a one-liner on each:&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;# DigitalOcean&lt;/span&gt;
doctl compute droplet create app-01 &lt;span class="nt"&gt;--size&lt;/span&gt; s-2vcpu-4gb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; ubuntu-24-04-x64 &lt;span class="nt"&gt;--region&lt;/span&gt; fra1

&lt;span class="c"&gt;# Vultr&lt;/span&gt;
vultr-cli instance create &lt;span class="nt"&gt;--plan&lt;/span&gt; vhf-2c-4gb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--os&lt;/span&gt; 2284 &lt;span class="nt"&gt;--region&lt;/span&gt; fra &lt;span class="nt"&gt;--label&lt;/span&gt; app-01

&lt;span class="c"&gt;# Linode&lt;/span&gt;
linode-cli linodes create &lt;span class="nt"&gt;--type&lt;/span&gt; g6-standard-2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; linode/ubuntu24.04 &lt;span class="nt"&gt;--region&lt;/span&gt; eu-central &lt;span class="nt"&gt;--label&lt;/span&gt; app-01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The differences show up past the happy path. DigitalOcean's API is the most consistent and best documented, with predictable pagination, useful error messages, and mature SDKs; it's the one we'd hand to a junior engineer without a walkthrough. Linode's API v4 is clean and well-designed, arguably the nicest REST semantics of the three, with an excellent official CLI. Vultr's API covers everything but carries more legacy quirks: some operations behave asynchronously without clearly saying so, and you'll write more polling loops than you would against the other two.&lt;/p&gt;

&lt;p&gt;For a solo developer this is a tiebreaker at most. For an agency provisioning a server per client, or a platform (like ours) that talks to all three APIs every day, it compounds: webhook reliability, rate limit sanity, and error clarity determine how much retry-and-reconcile code you end up maintaining. Ecosystem rankings that put DigitalOcean first on tooling reflect our experience exactly (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Running All Three on Deploynix
&lt;/h2&gt;

&lt;p&gt;We'll declare our position: we don't think you should have to marry a provider. DigitalOcean, Vultr, and Linode are all first-class providers on Deploynix, alongside Hetzner, AWS, and custom servers, and the provisioning flow is identical on each. You connect an API token, pick a server type (app, web, database, cache, worker, Meilisearch, or load balancer), pick a plan and region, and get the same hardened stack with monitoring, free SSL, backups to S3-compatible storage, and zero-downtime deploys, regardless of whose logo is on the invoice.&lt;/p&gt;

&lt;p&gt;That symmetry changes the economics of this whole comparison. When switching costs are near zero, you can pick per workload instead of per vendor: a Vultr High Frequency box for the app server because the price is right, a Linode for the staging environment because the Nanode is the best $5 in the business, and DigitalOcean managed MySQL because the ecosystem argument won for your database. One dashboard, one deploy pipeline, three providers. We wrote up how that works day-to-day in &lt;a href="https://deploynix.io/blog/managing-servers-across-6-cloud-providers-from-one-dashboard" rel="noopener noreferrer"&gt;managing servers across six cloud providers from one dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It also makes migration boring, which is the highest compliment infrastructure can receive. Moving a Laravel app from DigitalOcean to Vultr (or the reverse) is: provision an equivalent server on the new provider, deploy the same app to it from the same Git repo, import the database, and cut DNS over once you've verified the new box. No re-learning a provisioning system, no rewritten deploy scripts, no config drift between the old and new servers, because both were built from the same recipe. Teams do this to chase a better price, escape a noisy region, or consolidate billing, and it's an afternoon, not a quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verdict: Match the Provider to the Job
&lt;/h2&gt;

&lt;p&gt;We promised real verdicts, so here they are, based on the numbers above and our own operational experience across all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hobby project or side project: Linode.&lt;/strong&gt; The ~$5 Nanode's 1 GB of RAM is the entire argument. It's the only entry-tier plan of the three that runs a full Laravel stack (Nginx, PHP-FPM, MySQL, Redis) without constant memory anxiety, and the price-to-memory math is the best at the bottom of the market (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte&lt;/a&gt;). Vultr's $4 NVMe plan is the runner-up if your database lives elsewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small SaaS in production: Vultr High Frequency, with DigitalOcean Premium a close second.&lt;/strong&gt; At the 4 GB / 2 vCPU tier where most Laravel apps live, Vultr's $24 High Frequency plan pairs fast clocks with 128 GB of NVMe and the lowest effective price. Choose DigitalOcean instead if you want managed MySQL, Spaces, and load balancers from the same account, and accept a few dollars of ecosystem tax. Watch the transfer line: if you serve heavy assets from the origin, Linode's pooled 4 TB and half-price overage quietly wins the real-bill comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agency running many client servers: DigitalOcean.&lt;/strong&gt; When you're operating twenty servers for twelve clients, docs, API polish, and product breadth beat a $4-per-server saving. The best tutorial library in the industry reduces your support burden, and the cleanest API reduces your automation burden (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). Keep Vultr in your pocket for clients with users in regions DigitalOcean doesn't reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EU-first and budget-driven: none of the above.&lt;/strong&gt; Hetzner beats all three on raw price-to-performance in Europe, and pretending otherwise would make the rest of this post less trustworthy. The &lt;a href="https://deploynix.io/blog/hetzner-deploynix-the-best-price-to-performance-combo-for-laravel-in-europe" rel="noopener noreferrer"&gt;full Hetzner breakdown&lt;/a&gt; has the numbers.&lt;/p&gt;

&lt;p&gt;The pattern behind all four verdicts: compute prices converged, so pick on the second-order factors that fit your situation, and keep your setup portable enough that the decision stays reversible.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Which provider is fastest for Laravel specifically?
&lt;/h3&gt;

&lt;p&gt;There's no consistent winner. Independent tests at &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt; show the three trading places by region and test run. What moves the needle for Laravel is plan generation, not vendor: pick NVMe-backed, current-CPU plans (Vultr High Frequency, DigitalOcean Premium, Linode's current shared tier) and you'll be within a few percent either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is 1 GB of RAM enough to run Laravel in production?
&lt;/h3&gt;

&lt;p&gt;For a low-traffic app, yes, if the whole stack is tuned for it: 3 to 4 PHP-FPM workers, a capped MySQL buffer pool, and OPcache enabled. It's the practical floor, which is why Linode's 1 GB entry plan beats the 512 MB plans from DigitalOcean and Vultr for single-server setups. For anything with real users or queue volume, budget for the 4 GB tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use managed MySQL or run the database on my own VPS?
&lt;/h3&gt;

&lt;p&gt;Self-host until downtime costs you real money. Entry managed plans (from roughly $15/month at the time of writing) are single-node, so you pay the premium without getting failover. A $6 to $12 dedicated database VPS with automated off-site backups covers the same risk for half the price. Move to managed once you need the HA tier with a standby node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I mix providers, say the app on Vultr and staging on Linode?
&lt;/h3&gt;

&lt;p&gt;Yes, and it's more practical than it sounds. Since Deploynix provisions DigitalOcean, Vultr, Linode, Hetzner, AWS, and custom servers through the identical flow, mixed setups behave like one environment: same deploy pipeline, same monitoring, same backup targets. The only real constraint is keeping the app and its database in the same region and network, whichever provider that is.&lt;/p&gt;

&lt;h3&gt;
  
  
  How painful is it to switch providers later?
&lt;/h3&gt;

&lt;p&gt;If your server was built from a reproducible recipe, it's an afternoon: provision the new server, deploy from the same repo, import the database, verify, and cut DNS over with a low TTL. The pain people remember comes from hand-configured servers where nobody knows what's on them. Keep the setup scripted or platform-managed and provider choice stays a reversible decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick One and Ship
&lt;/h2&gt;

&lt;p&gt;Here's our closing advice: the cost of deliberating over these three for another week is higher than the cost of picking the "wrong" one. The prices are within dollars of each other, the performance is within percent, and a well-scripted setup makes the choice reversible in an afternoon. Apply the verdicts above (Linode for the $5 experiment, Vultr or DigitalOcean for the production 4 GB box, DigitalOcean for the agency fleet), then spend your energy on the app instead of the vendor.&lt;/p&gt;

&lt;p&gt;Where to go from here: pick the provider that matched your situation, grab an API token, and &lt;a href="https://deploynix.io/blog/deploy-your-first-laravel-app-in-under-10-minutes-with-deploynix" rel="noopener noreferrer"&gt;deploy your first Laravel app in under 10 minutes&lt;/a&gt;. You'll learn more from one deployed server than from ten more comparison posts, including this one.&lt;/p&gt;

</description>
      <category>digitalocean</category>
      <category>vultr</category>
      <category>linode</category>
      <category>comparison</category>
    </item>
    <item>
      <title>Migrating From Laravel Vapor to Deploynix: Trading Serverless for Servers</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 27 Aug 2026 19:39:33 +0000</pubDate>
      <link>https://dev.to/deploynix/migrating-from-laravel-vapor-to-deploynix-trading-serverless-for-servers-3f4o</link>
      <guid>https://dev.to/deploynix/migrating-from-laravel-vapor-to-deploynix-trading-serverless-for-servers-3f4o</guid>
      <description>&lt;p&gt;There is a specific moment when serverless economics invert, and most teams can name the month it happened. Your Laravel app used to have spiky, unpredictable traffic, and Lambda's pay-per-millisecond model was a bargain. Then the product found its audience. Traffic became steady: a predictable daily curve, the same few thousand users, the same queue volume every hour. From that point on, you are paying a premium for elasticity you no longer use. The bill stops scaling to zero because your traffic never does.&lt;/p&gt;

&lt;p&gt;We have talked to a lot of Laravel teams in exactly this position. They chose Vapor for good reasons, and Vapor delivered. But somewhere between product-market fit and the third month of a four-figure AWS invoice, someone opens Cost Explorer, squints at the NAT gateway line item, and asks the uncomfortable question: what would this cost on two ordinary servers?&lt;/p&gt;

&lt;p&gt;Usually the answer is 60 to 80 percent less, with faster p50 latencies and a log file you can actually tail. This post is the honest version of that conversation. We will cover when Vapor is still the right call, how to audit your app before moving, a step-by-step migration runbook including a queue cutover that does not lose jobs, and a worked cost comparison at steady load. If you want the broader argument first, we made it 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;. This post assumes you have already decided to look seriously at moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Vapor Is the Right Call
&lt;/h2&gt;

&lt;p&gt;Let's be fair before we get to the migration, because Vapor is a genuinely well-built product and some teams should absolutely stay on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;True burst handling. If your traffic goes from 50 requests per minute to 5,000 in thirty seconds, Lambda absorbs it without you touching anything. No autoscaling groups, no capacity planning, no 3 a.m. pages about load. This is the killer feature, and no fixed-size server setup matches it.&lt;/li&gt;
&lt;li&gt;Zero server operations. No OS patching, no PHP upgrades on a box, no disk space alerts, no SSH. For a solo founder or a team with no infrastructure appetite, that has real value.&lt;/li&gt;
&lt;li&gt;Per-millisecond billing on idle apps. A staging environment or an internal tool that gets ten requests a day costs almost nothing on Lambda. A VPS costs the same whether it serves one request or one million.&lt;/li&gt;
&lt;li&gt;First-party Laravel integration. Vapor is built by the Laravel team. Queues, scheduled tasks, environment management, and deployments all work the way the framework documentation assumes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Apps with genuinely spiky or unpredictable traffic (ticket sales, viral consumer products, campaign-driven marketing sites), teams with zero ops capacity, and low-traffic apps where the bill really does round to pocket change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Steady traffic means you pay the serverless premium continuously for elasticity you use twice a year. Cold starts of 500ms to 2 seconds hit your least-trafficked endpoints hardest, which are often admin panels and API routes your paying customers notice. Lambda's 15-minute execution ceiling is a hard wall for long imports, big report generation, and video processing. NAT gateway hours and data processing charges appear as surprise line items the moment your functions talk to the internet from inside a VPC. And debugging means CloudWatch Logs Insights queries instead of &lt;code&gt;tail -f storage/logs/laravel.log&lt;/code&gt;, which sounds minor until you are doing it during an incident.&lt;/p&gt;

&lt;p&gt;The pattern is not unique to Vapor users. A Barclays CIO survey found that 83% of enterprises plan to repatriate at least some workloads from public cloud (via &lt;a href="https://northflank.com/blog/cloud-repatriation" rel="noopener noreferrer"&gt;Northflank&lt;/a&gt;), and teams that repatriate strategically report infrastructure savings of 30 to 60 percent (&lt;a href="https://massivegrid.com/blog/cloud-repatriation-2026-moving-off-aws-azure-gcp/" rel="noopener noreferrer"&gt;MassiveGRID&lt;/a&gt;). Worth stressing, though: only around 5% plan a full exit from the cloud (&lt;a href="https://advancedhosting.com/cloud-repatriation/" rel="noopener noreferrer"&gt;Advanced Hosting&lt;/a&gt;). Repatriation is a workload-by-workload decision, not an ideology. Your steady-traffic Laravel monolith is exactly the kind of workload where it pays off. Your S3 buckets and maybe your RDS instance can stay right where they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Know You've Hit the Crossover Point?
&lt;/h2&gt;

&lt;p&gt;The crossover is rarely one dramatic invoice. It creeps. Here are the signals we see most often in teams that eventually migrate, roughly in the order they appear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your warming bill exists at all.&lt;/strong&gt; Vapor's &lt;code&gt;warm&lt;/code&gt; setting keeps Lambda containers alive so users do not eat cold starts. Read that again: you are paying for idle compute to simulate a server. Once warming instances run around the clock, you have reinvented a fixed server with worse economics. The moment &lt;code&gt;warm: 10&lt;/code&gt; feels necessary in production, the elasticity argument has already lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold starts hit the endpoints you can least afford.&lt;/strong&gt; High-traffic routes stay warm naturally. It is the low-traffic ones that go cold: the admin panel your biggest customer's ops team uses, the API endpoint a partner polls hourly, the password reset flow. A 1.5-second stall on a checkout-adjacent route is a support ticket; on a sales demo, it is worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The queue backlog keeps hitting the 15-minute wall.&lt;/strong&gt; If your team has ever chunked a job, not because chunking was good design but because Lambda would kill it at minute fifteen, count how many of those workarounds exist. Each one is architecture bent around a billing model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The invoice needs a translator.&lt;/strong&gt; When "why did the NAT gateway cost $60 this month?" is a real question in your Slack, and answering it takes an hour in Cost Explorer, add that engineering time to the bill. Same for incidents: if reconstructing one request means stitching together three CloudWatch log groups, your mean-time-to-understanding is paying the serverless tax too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traffic graphs look like sine waves, not earthquakes.&lt;/strong&gt; Pull 90 days of request metrics. If peak hour is within 3 to 5x of the quiet hour, every week, you have a capacity-plannable app. Provision a server for peak with headroom and pocket the difference.&lt;/p&gt;

&lt;p&gt;Two or three of these and you are past the crossover. All five, and this migration is overdue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does the Vapor-to-Server Concept Mapping Look Like?
&lt;/h2&gt;

&lt;p&gt;The good news: nothing about Vapor changes your application code in ways that block a move. Laravel was built for servers first. Vapor adapted it to Lambda; you are adapting it back. Every Vapor concept has a direct server-side equivalent.&lt;/p&gt;

&lt;p&gt;Vapor / AWS concept&lt;/p&gt;

&lt;p&gt;Server equivalent&lt;/p&gt;

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

&lt;p&gt;Lambda HTTP function&lt;/p&gt;

&lt;p&gt;Nginx + PHP-FPM worker pool (or Octane)&lt;/p&gt;

&lt;p&gt;Always warm. No cold starts, no per-invocation billing.&lt;/p&gt;

&lt;p&gt;SQS queues&lt;/p&gt;

&lt;p&gt;Database or Valkey/Redis queue + Supervisor-managed workers&lt;/p&gt;

&lt;p&gt;&lt;code&gt;QUEUE_CONNECTION=database&lt;/code&gt; or &lt;code&gt;redis&lt;/code&gt;. Workers restart automatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vapor.yml&lt;/code&gt; build/deploy steps&lt;/p&gt;

&lt;p&gt;Deploynix deploy config + deploy hooks&lt;/p&gt;

&lt;p&gt;Same idea: composer install, npm build, migrate, cache.&lt;/p&gt;

&lt;p&gt;RDS database&lt;/p&gt;

&lt;p&gt;Stays on RDS initially, or moves to a database server later&lt;/p&gt;

&lt;p&gt;Connecting to RDS from a VPS is fine. Decouple this decision from the migration.&lt;/p&gt;

&lt;p&gt;CloudFront&lt;/p&gt;

&lt;p&gt;Cloudflare (or any CDN) in front of your server&lt;/p&gt;

&lt;p&gt;Free tier covers most Laravel apps.&lt;/p&gt;

&lt;p&gt;Vapor environment variables and secrets&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; per environment, managed in the dashboard&lt;/p&gt;

&lt;p&gt;Same values, different transport.&lt;/p&gt;

&lt;p&gt;Scheduled tasks (&lt;code&gt;scheduler: true&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Cron entry running &lt;code&gt;php artisan schedule:run&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;One line, managed via UI.&lt;/p&gt;

&lt;p&gt;ElastiCache / Vapor cache&lt;/p&gt;

&lt;p&gt;Valkey on the same box or a dedicated cache server&lt;/p&gt;

&lt;p&gt;Sub-millisecond, no network hop for single-server setups.&lt;/p&gt;

&lt;p&gt;CloudWatch Logs&lt;/p&gt;

&lt;p&gt;&lt;code&gt;storage/logs/laravel.log&lt;/code&gt; + log shipping if you want it&lt;/p&gt;

&lt;p&gt;You can tail it again.&lt;/p&gt;

&lt;p&gt;Two things on this table deserve emphasis. First, &lt;strong&gt;RDS can stay&lt;/strong&gt;. A common migration mistake is trying to move compute and data on the same day. Don't. Deploynix provisions servers on AWS as well as DigitalOcean, Vultr, Linode, and Hetzner, so you can put your app server in the same AWS region as your RDS instance, keep single-digit-millisecond database latency, and defer the database decision entirely. Second, &lt;strong&gt;S3 stays S3&lt;/strong&gt;. Your &lt;code&gt;FILESYSTEM_DISK=s3&lt;/code&gt; config does not care whether the code calling it runs on Lambda or a VPS. Do not migrate object storage as part of this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pre-Migration Audit
&lt;/h2&gt;

&lt;p&gt;An afternoon of auditing saves a weekend of firefighting. Before provisioning anything, work through your &lt;code&gt;vapor.yml&lt;/code&gt; and your codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inventory your vapor.yml
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;vapor.yml&lt;/code&gt; is the map of everything the migration has to replace. A typical mid-size app looks something like this:&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;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12345&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme-app&lt;/span&gt;
&lt;span class="na"&gt;environments&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;
    &lt;span class="na"&gt;cli-memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;512&lt;/span&gt;
    &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-8.3&lt;/span&gt;
    &lt;span class="na"&gt;queues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;notifications&lt;/span&gt;
    &lt;span class="na"&gt;queue-memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;
    &lt;span class="na"&gt;queue-timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
    &lt;span class="na"&gt;warm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme-production&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme-cache&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;composer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;install&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--no-dev'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;php&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;artisan&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;event:cache'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ci&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-rf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;node_modules'&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;php&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;artisan&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrate&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--force'&lt;/span&gt;
  &lt;span class="na"&gt;staging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;512&lt;/span&gt;
    &lt;span class="na"&gt;queues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
    &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each environment, write down: the queue names and their timeouts, whether the scheduler is on, the attached database and cache, every build and deploy step, and the warming configuration (that &lt;code&gt;warm: 10&lt;/code&gt; line is ten Lambda containers being pinged around the clock; it becomes irrelevant on a server, and it was costing you money). Then pull the environment variables and secrets for each environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vapor &lt;span class="nb"&gt;env&lt;/span&gt;:pull production
&lt;span class="c"&gt;# secrets are listed in the Vapor dashboard; export them alongside the env file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the result somewhere safe. This file becomes your server &lt;code&gt;.env&lt;/code&gt; almost verbatim.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find Lambda-shaped code assumptions
&lt;/h3&gt;

&lt;p&gt;Vapor forced some patterns onto your codebase. Most of them are harmless on a server, and a few actually become opportunities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ephemeral &lt;code&gt;/tmp&lt;/code&gt; workarounds. Code that streams uploads straight to S3 because local disk did not persist keeps working unchanged. But jobs that awkwardly chunked work through S3 to dodge the missing filesystem can be simplified: you have a persistent local disk again.&lt;/li&gt;
&lt;li&gt;The 15-minute ceiling. Search for jobs that were split into chains purely to fit under Lambda's timeout. On a server, a queue worker can run a four-hour job if you let it. You do not have to refactor these on day one, but flag them.&lt;/li&gt;
&lt;li&gt;Package and image size limits. Vapor's Docker runtime caps images at 10GB and the standard runtime is far tighter, so you may have excluded binaries like &lt;code&gt;wkhtmltopdf&lt;/code&gt;, FFmpeg, or heavy PHP extensions. On a server you just install them.&lt;/li&gt;
&lt;li&gt;Runtime assumptions. Check for hardcoded references to Vapor helpers or the &lt;code&gt;VAPOR_*&lt;/code&gt; environment variables, and confirm &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; handling once you are behind Cloudflare instead of CloudFront.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Decide where the database lives (for now)
&lt;/h3&gt;

&lt;p&gt;The right answer during migration is: exactly where it is today. Add your VPS's IP to the RDS security group, point &lt;code&gt;DB_HOST&lt;/code&gt; at the RDS endpoint, and move on. Once the migration has been stable for a month, you can evaluate moving to a Deploynix-provisioned database server with automated backups shipped to S3-compatible storage. That is a separate project with its own dump-and-restore cutover, and we cover the economics of it in &lt;a href="https://deploynix.io/blog/the-real-cost-of-running-a-laravel-saas-monthly-infrastructure-breakdown" rel="noopener noreferrer"&gt;the real cost of running a Laravel SaaS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Runbook
&lt;/h2&gt;

&lt;p&gt;Here is the sequence we recommend, in order, with the queue cutover treated as the delicate part it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Provision the app and worker servers
&lt;/h3&gt;

&lt;p&gt;Connect a cloud provider to Deploynix (DigitalOcean, Vultr, Linode, Hetzner, AWS, or a custom server) and provision two servers: an &lt;strong&gt;app server&lt;/strong&gt; (Nginx, PHP-FPM, your PHP version) and a &lt;strong&gt;worker server&lt;/strong&gt; for queues and scheduled tasks. Small teams can run everything on one box; separating web and worker traffic means a runaway job cannot starve your HTTP requests. If you are staying near RDS, provision on AWS in the same region.&lt;/p&gt;

&lt;p&gt;Connect your GitHub, GitLab, or Bitbucket repository, and translate your &lt;code&gt;vapor.yml&lt;/code&gt; build and deploy steps into the deploy configuration and deploy hooks. The mapping is almost one-to-one:&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;# Deploy script (runs on each release)&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;--optimize-autoloader&lt;/span&gt;
npm ci &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build
php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan config:cache
php artisan event:cache
php artisan queue:restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploys are zero-downtime by default: each push builds a new release directory, and a symlink swap makes it live, with one-click rollback to the previous release. This replaces Vapor's deploy pipeline, including the rollback story.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Move environment variables
&lt;/h3&gt;

&lt;p&gt;Take the file from &lt;code&gt;vapor env:pull&lt;/code&gt; and load it into your server's environment through the dashboard. The changes are small and predictable:&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;APP_ENV&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="py"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;

&lt;span class="py"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mysql&lt;/span&gt;
&lt;span class="py"&gt;DB_HOST&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;acme-production.xxxx.eu-west-1.rds.amazonaws.com  # RDS stays, for now&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;database   # was sqs&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           # Valkey on the worker/cache server, was ElastiCache&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;database     # survives the cutover across both platforms&lt;/span&gt;

&lt;span class="py"&gt;FILESYSTEM_DISK&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;s3          # unchanged, S3 stays S3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the &lt;code&gt;SQS_*&lt;/code&gt; and &lt;code&gt;VAPOR_*&lt;/code&gt; variables only after the cutover is complete. During the parallel window, both platforms run with their own queue connection, which is exactly what makes the next step safe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Cut over the queues without losing a job
&lt;/h3&gt;

&lt;p&gt;This is the step people worry about, and the strategy is simple: &lt;strong&gt;new jobs go to the new queue, old jobs drain from the old one, and for a while both sets of workers run.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the new servers, configure queue workers through the Deploynix UI: connection, queue names (&lt;code&gt;default&lt;/code&gt;, &lt;code&gt;notifications&lt;/code&gt;, matching your &lt;code&gt;vapor.yml&lt;/code&gt;), process count, timeout, and retry settings. Deploynix writes the Supervisor configuration for you, so workers restart on failure and on deploy.&lt;/li&gt;
&lt;li&gt;Deploy the app to the new servers with &lt;code&gt;QUEUE_CONNECTION=database&lt;/code&gt; (or &lt;code&gt;redis&lt;/code&gt;). At this point the new stack is dispatching to and consuming from its own queues.&lt;/li&gt;
&lt;li&gt;Leave Vapor's queue workers running. They keep consuming whatever is still in SQS, including delayed and released jobs.&lt;/li&gt;
&lt;li&gt;Watch the SQS console until &lt;code&gt;ApproximateNumberOfMessages&lt;/code&gt; and the not-visible count both sit at zero, and stay there. Remember delayed jobs: if you dispatch jobs with long delays, the drain window must be at least as long as your longest delay.&lt;/li&gt;
&lt;li&gt;Only then disable Vapor's queue processing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mind your &lt;code&gt;failed_jobs&lt;/code&gt; table during the window: jobs can fail on either platform, and you want &lt;code&gt;queue:retry&lt;/code&gt; run from the side that owns the job. If you have not already built retry and timeout discipline into your jobs, do it before the migration, not after; our &lt;a href="https://deploynix.io/blog/laravel-queues-deep-dive-connections-workers-and-retry-strategies-on-deploynix" rel="noopener noreferrer"&gt;queues deep dive on connections, workers, and retry strategies&lt;/a&gt; covers the settings that matter, and &lt;a href="https://deploynix.io/blog/debugging-laravel-queue-failures-in-production-without-losing-messages" rel="noopener noreferrer"&gt;debugging Laravel queue failures without losing messages&lt;/a&gt; is the companion piece for when something does go sideways mid-cutover.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Move the scheduler
&lt;/h3&gt;

&lt;p&gt;On Vapor, &lt;code&gt;scheduler: true&lt;/code&gt; ran your schedule through a CLI Lambda. On the worker server, it is one cron entry, configured through the UI:&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; php /home/deploynix/app/current/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;One caution for the parallel window: if the scheduler runs on both platforms simultaneously, tasks fire twice. Use &lt;code&gt;-&amp;gt;onOneServer()&lt;/code&gt; with a shared cache store, or simpler, disable Vapor's scheduler at the moment you enable cron. For most apps a sixty-second gap in scheduling is harmless; double execution often is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: DNS cutover and the parallel window
&lt;/h3&gt;

&lt;p&gt;Drop your DNS TTL to 60 seconds a day before the cutover. Issue the SSL certificate for your domain on the new server (Deploynix handles Let's Encrypt issuance and renewal for free), and verify the new stack end to end using a hosts-file override or a temporary subdomain: login, checkout, webhooks, file uploads, a full queue round-trip.&lt;/p&gt;

&lt;p&gt;Then switch DNS from Vapor's CloudFront distribution to the new server (or to Cloudflare proxying the new server, which replaces CloudFront's CDN role). Because sessions are in the database and both stacks talk to the same RDS instance, users whose DNS resolves to either side during propagation get a consistent experience.&lt;/p&gt;

&lt;p&gt;Keep Vapor fully deployed and warm for at least a week. That is your rollback: if anything is wrong, point DNS back and you are on the old stack within minutes. Rollback for bad deploys on the new stack is separate and faster: one click back to the previous release. Only delete the Vapor environments when the new stack has survived a full weekly cycle, including your heaviest scheduled jobs.&lt;/p&gt;

&lt;p&gt;If parts of this runbook feel familiar, it is because the shape is the same for any platform exit; we walked through the PaaS variant in &lt;a href="https://deploynix.io/blog/migrating-laravel-from-railway-render-or-flyio-to-a-vps" rel="noopener noreferrer"&gt;migrating Laravel from Railway, Render, or &lt;/a&gt;&lt;a href="http://Fly.io" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;&lt;a href="https://deploynix.io/blog/migrating-laravel-from-railway-render-or-flyio-to-a-vps" rel="noopener noreferrer"&gt; to a VPS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Do You Actually Get Back?
&lt;/h2&gt;

&lt;p&gt;The cost savings get the headlines, but talk to teams six months after this migration and they lead with the capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A persistent local disk.&lt;/strong&gt; Temporary files, on-disk caches, generated exports that live for an hour before download: all trivially possible again. You stop paying S3 round-trip latency for scratch work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-running jobs.&lt;/strong&gt; The 15-minute ceiling is gone. A nightly report that takes 40 minutes is just a queue job with &lt;code&gt;$timeout = 3600&lt;/code&gt; and a worker that is allowed to take its time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time WebSockets on the same box.&lt;/strong&gt; This one is binary: Vapor cannot host persistent socket connections, so Reverb is off the table there and you are pushed to Pusher or Ably as a paid external dependency. On a Deploynix server, Reverb runs as a daemon under Supervisor next to your app, and broadcasting works the way the Laravel docs describe, with no per-message pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First-class queue tooling.&lt;/strong&gt; SQS through Vapor works, but you give up Laravel Horizon, which requires Redis. On a server with Valkey as your queue connection, Horizon runs as a daemon and gives you per-queue throughput, wait times, and failed-job retries in a dashboard your whole team can read. For queue-heavy apps, this alone changes how quickly you diagnose problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boring, predictable bills.&lt;/strong&gt; Two fixed-price servers cost the same in your busiest month and your quietest one. Finance can forecast it. Nobody audits Cost Explorer looking for the line item that doubled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct debuggability.&lt;/strong&gt; SSH in, &lt;code&gt;tail -f&lt;/code&gt; the log, run &lt;code&gt;php artisan tinker&lt;/code&gt; against production with appropriate care, watch &lt;code&gt;htop&lt;/code&gt; during an incident. CloudWatch is powerful, but during an outage, immediacy beats query languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Take On, and How It's Mitigated
&lt;/h2&gt;

&lt;p&gt;Honesty cuts both ways: leaving Vapor means someone is responsible for servers again. The real question is how much of that responsibility gets automated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security and patching.&lt;/strong&gt; A public server needs a firewall, SSH hardening, fail2ban, and regular updates. Deploynix provisions servers hardened by default (key-only SSH, UFW configured, automatic security updates) so the baseline does not depend on you remembering it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups.&lt;/strong&gt; On Vapor, RDS snapshots were your safety net; if your database eventually moves off RDS, backups become your problem. Automated database backups to S3-compatible storage, on a schedule, with retention, are built in. Test a restore once a quarter regardless of platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring and capacity.&lt;/strong&gt; Lambda scaled silently; a server has finite CPU, RAM, and disk. Built-in monitoring with alerts covers the basics (load, memory, disk, service health), so you hear about a filling disk days before it becomes an incident. And when you genuinely outgrow one app server, you add a second behind a Deploynix-provisioned load balancer. That is a scaling step, not an emergency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The elasticity trade.&lt;/strong&gt; This is the one real loss. If your traffic can spike 50x in a minute, fixed servers need headroom or a CDN absorbing the burst, and neither is as effortless as Lambda. Be honest about whether that describes your traffic. For most steady-state SaaS apps, it does not, and provisioning 3x headroom still costs a fraction of the serverless equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked Example: What Does Steady Load Cost on Each?
&lt;/h2&gt;

&lt;p&gt;Numbers make this concrete. Take a representative mid-size Laravel SaaS at steady load: about 5 million HTTP requests a month averaging 250ms at 1GB memory, 2 million queue jobs a month averaging 2 seconds, an RDS MySQL instance, a small Redis cache, CDN in front, and functions inside a VPC (which means a NAT gateway). Approximate monthly costs at current us-east/eu-west pricing:&lt;/p&gt;

&lt;p&gt;Line item&lt;/p&gt;

&lt;p&gt;Vapor stack&lt;/p&gt;

&lt;p&gt;Two-server Deploynix stack&lt;/p&gt;

&lt;p&gt;HTTP compute&lt;/p&gt;

&lt;p&gt;Lambda: ~$21 + API Gateway: ~$5&lt;/p&gt;

&lt;p&gt;App server (4 vCPU / 8GB, DigitalOcean): $24&lt;/p&gt;

&lt;p&gt;Queue compute&lt;/p&gt;

&lt;p&gt;Lambda workers: ~$66 + SQS: ~$1&lt;/p&gt;

&lt;p&gt;Worker server (2 vCPU / 4GB): $18&lt;/p&gt;

&lt;p&gt;Warming (10 instances)&lt;/p&gt;

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

&lt;p&gt;n/a (always warm)&lt;/p&gt;

&lt;p&gt;NAT gateway&lt;/p&gt;

&lt;p&gt;~$42 (hours + data processing)&lt;/p&gt;

&lt;p&gt;n/a&lt;/p&gt;

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

&lt;p&gt;RDS db.t4g.medium + storage: ~$58&lt;/p&gt;

&lt;p&gt;Same RDS instance: ~$58 (unchanged)&lt;/p&gt;

&lt;p&gt;Cache&lt;/p&gt;

&lt;p&gt;ElastiCache t4g.micro: ~$12&lt;/p&gt;

&lt;p&gt;Valkey on worker server: $0&lt;/p&gt;

&lt;p&gt;CDN&lt;/p&gt;

&lt;p&gt;CloudFront: ~$10&lt;/p&gt;

&lt;p&gt;Cloudflare free tier: $0&lt;/p&gt;

&lt;p&gt;Logging&lt;/p&gt;

&lt;p&gt;CloudWatch: ~$15&lt;/p&gt;

&lt;p&gt;Local logs: $0&lt;/p&gt;

&lt;p&gt;Platform fee&lt;/p&gt;

&lt;p&gt;Vapor: $39/project&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;~$277 + traffic growth&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~$100 + flat&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two observations. First, the compute you actually think about (Lambda HTTP) is not the expensive part; the queue workers, NAT gateway, warming, and observability plumbing around it are. Second, the server column barely moves as traffic grows: the same $24 app server that handles 5 million requests a month handles 15 million, while every Lambda line item scales linearly. At steady load, that roughly $175 monthly gap is the serverless premium, and it widens every month your traffic grows. Squeeze further by moving the database off RDS later, or by provisioning on Hetzner, and the gap gets embarrassing; the fuller teardown lives in &lt;a href="https://deploynix.io/blog/cloud-repatriation-for-laravel-from-aws-to-a-10-vps" rel="noopener noreferrer"&gt;cloud repatriation for Laravel: from AWS to a $10 VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Your numbers will differ. Run yours before deciding; the audit in this post gives you every input you need.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can I keep RDS and still migrate off Vapor?
&lt;/h3&gt;

&lt;p&gt;Yes, and we recommend it. Provision your app server on AWS in the same region as your RDS instance, allow its IP in the RDS security group, and point &lt;code&gt;DB_HOST&lt;/code&gt; at the endpoint. Latency stays in single-digit milliseconds, and you have decoupled the risky database move from the compute migration entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does the migration take?
&lt;/h3&gt;

&lt;p&gt;For a typical app: an afternoon for the audit, a day to provision and configure the new stack, and a one-to-two week parallel window before you decommission Vapor. The active cutover itself (queue switch plus DNS) is an evening. The parallel window is not wasted time; it is your rollback insurance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will I lose queued jobs during the cutover?
&lt;/h3&gt;

&lt;p&gt;Not if you drain rather than switch. New servers dispatch to the new queue connection while Vapor's workers keep consuming SQS until it is empty, including delayed jobs. Both worker fleets run simultaneously during the window. The only way to lose jobs is to turn off SQS consumption before the backlog and delay horizon are clear, so don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Octane? We used it on Vapor for performance.
&lt;/h3&gt;

&lt;p&gt;Octane runs at least as well on a server, arguably better, because workers persist indefinitely instead of living inside Lambda's lifecycle. Deploynix supports Octane with FrankenPHP, Swoole, or RoadRunner running as a daemon under Supervisor. Plenty of teams migrate to plain PHP-FPM first and adopt Octane later once they have baseline numbers to compare.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this a full "leave AWS" move?
&lt;/h3&gt;

&lt;p&gt;No, and framing it that way leads to bad decisions. Only about 5% of organizations plan full repatriation (&lt;a href="https://advancedhosting.com/cloud-repatriation/" rel="noopener noreferrer"&gt;Advanced Hosting&lt;/a&gt;); the sensible pattern is workload by workload. In this migration, S3 stays, RDS can stay, and only the compute layer (the part with the worst steady-load economics) moves to servers.&lt;/p&gt;

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

&lt;p&gt;Vapor earned its place in the Laravel ecosystem, and if your traffic is genuinely spiky or your ops appetite is genuinely zero, staying is defensible. But if your dashboard shows the same steady curve every week, you are renting elasticity you do not use, and the exit is more mechanical than it looks: map the concepts, audit the &lt;code&gt;vapor.yml&lt;/code&gt;, drain the queues, flip DNS, keep the old stack warm for a week.&lt;/p&gt;

&lt;p&gt;The single next step: run the audit. Pull your &lt;code&gt;vapor.yml&lt;/code&gt; and your last three months of AWS invoices, fill in the cost table above with your own numbers, and see where you land. If the gap looks like ours, provision a test server on Deploynix, deploy a staging copy of your app, and put real traffic through it before you commit to anything. The numbers will tell you whether this migration is worth your weekend. In our experience, for steady-traffic Laravel apps, they almost always do.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>vapor</category>
      <category>serverless</category>
      <category>migration</category>
    </item>
    <item>
      <title>ARM vs. x86 Servers for Laravel: Should You Switch in 2026?</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sun, 23 Aug 2026 11:18:03 +0000</pubDate>
      <link>https://dev.to/deploynix/arm-vs-x86-servers-for-laravel-should-you-switch-in-2026-5dmc</link>
      <guid>https://dev.to/deploynix/arm-vs-x86-servers-for-laravel-should-you-switch-in-2026-5dmc</guid>
      <description>&lt;p&gt;Here is a number that should get your attention: at Hetzner, a 4 vCPU / 8 GB ARM server (the CAX31, running Ampere Altra cores) launched at €11.99 per month, while the equivalent x86 CPX31 (4 vCPU AMD EPYC, 8 GB) sat at €16.49 (&lt;a href="https://betterstack.com/community/guides/web-servers/hetzner-cloud-review/" rel="noopener noreferrer"&gt;Better Stack&lt;/a&gt;). Same vCPU count, same RAM, roughly 27% cheaper. Hetzner announced price rises effective April 2026 that moved those figures to €15.99 and €21.49 respectively, and the gap barely budged. At the time of writing, choosing ARM still saves you about €5.50 per month per server, or around 26%.&lt;/p&gt;

&lt;p&gt;That discount is not a promotion or a loss leader. It reflects a real difference in how much these chips cost to buy and power. And it raises an obvious question for anyone running Laravel in production: is there a catch, or are we all overpaying for x86 out of habit?&lt;/p&gt;

&lt;p&gt;The honest answer is that it depends on the shape of your traffic, and most articles on this topic skip that part. ARM cores are typically 15 to 20% slower per single thread, which matters for PHP because each request runs on exactly one thread. But your server does not handle one request. It handles dozens of concurrent requests across a fleet of PHP-FPM workers, and that is a parallel workload where ARM's core count economics shine.&lt;/p&gt;

&lt;p&gt;We've provisioned both architectures for Laravel apps enough times to have opinions grounded in production behavior rather than synthetic benchmarks alone. This post walks through the price math, the performance trade-off that actually matters for PHP, the state of arm64 compatibility in 2026, and a migration path that will not take your app down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Did ARM Servers Suddenly Get Cheap?
&lt;/h2&gt;

&lt;p&gt;ARM chips have dominated phones for two decades because they deliver more compute per watt. Servers were a different story for years: the software ecosystem assumed x86, and nobody wanted to be the first to port everything. Two things broke that logjam.&lt;/p&gt;

&lt;p&gt;First, AWS built Graviton. Amazon started shipping its own ARM server chips in 2018 and priced Graviton instances aggressively below their x86 equivalents. That forced the entire ecosystem, from Linux distributions to Docker to database vendors, to treat arm64 as a first-class target. When the largest cloud provider on earth bets on an architecture, package maintainers follow.&lt;/p&gt;

&lt;p&gt;Second, Ampere started selling ARM server chips to everyone else. The Ampere Altra, the chip inside Hetzner's CAX line, packs up to 80 cores on a single socket with predictable per-core performance and dramatically lower power draw than comparable x86 parts. For a hosting company, electricity and cooling are among the biggest line items in the datacenter budget. A chip that does comparable work on fewer watts translates directly into lower prices for you.&lt;/p&gt;

&lt;p&gt;There's a third, quieter factor: your laptop probably already runs ARM. Every Apple Silicon MacBook since 2020 is an arm64 machine. Millions of developers have spent five years unknowingly stress-testing the arm64 ecosystem in local development, filing issues against every Docker image and PECL extension that did not build. The ecosystem hardened because of it. We'll come back to why that helps you in the compatibility section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does the Price Gap Actually Look Like?
&lt;/h2&gt;

&lt;p&gt;Let's put the Hetzner numbers side by side, since Hetzner is where the ARM value proposition is most visible in Europe. Both servers below offer 4 vCPUs and 8 GB of RAM. The only meaningful spec difference is the silicon.&lt;/p&gt;

&lt;p&gt;CAX31 (ARM)&lt;/p&gt;

&lt;p&gt;CPX31 (x86)&lt;/p&gt;

&lt;p&gt;ARM Savings&lt;/p&gt;

&lt;p&gt;CPU&lt;/p&gt;

&lt;p&gt;4 vCPU Ampere Altra&lt;/p&gt;

&lt;p&gt;4 vCPU AMD EPYC&lt;/p&gt;

&lt;p&gt;-&lt;/p&gt;

&lt;p&gt;RAM&lt;/p&gt;

&lt;p&gt;8 GB&lt;/p&gt;

&lt;p&gt;8 GB&lt;/p&gt;

&lt;p&gt;-&lt;/p&gt;

&lt;p&gt;Price before April 2026&lt;/p&gt;

&lt;p&gt;€11.99/mo&lt;/p&gt;

&lt;p&gt;€16.49/mo&lt;/p&gt;

&lt;p&gt;€4.50/mo (~27%)&lt;/p&gt;

&lt;p&gt;Price from April 2026&lt;/p&gt;

&lt;p&gt;€15.99/mo&lt;/p&gt;

&lt;p&gt;€21.49/mo&lt;/p&gt;

&lt;p&gt;€5.50/mo (~26%)&lt;/p&gt;

&lt;p&gt;Prices are at the time of writing and sourced from &lt;a href="https://betterstack.com/community/guides/web-servers/hetzner-cloud-review/" rel="noopener noreferrer"&gt;Better Stack's Hetzner Cloud review&lt;/a&gt;, which also covers the April 2026 adjustment. Even after the increase, the relative discount held steady at roughly a quarter off.&lt;/p&gt;

&lt;p&gt;One server saving €5.50 a month is coffee money. But cost optimization compounds. If you run a web tier of three servers plus two queue workers, that is €27.50 a month, or €330 a year, for hardware that (as we'll see) may actually process more total requests for your workload. Scale that to an agency managing twenty client servers and the architecture choice starts paying a salary's worth of hosting bills over a few years.&lt;/p&gt;

&lt;p&gt;The catch, and there is one, is that a cheaper server is only cheaper if it does the same job. Better Stack's analysis puts ARM instances at roughly 20 to 40% better price-performance for compatible parallel workloads, but around 15 to 20% lower single-thread scores (&lt;a href="https://betterstack.com/community/guides/web-servers/hetzner-cloud-review/" rel="noopener noreferrer"&gt;Better Stack&lt;/a&gt;). Both halves of that sentence matter for Laravel, so let's unpack them.&lt;/p&gt;

&lt;p&gt;We've written before about why Hetzner in general is our value pick for European Laravel hosting in &lt;a href="https://deploynix.io/blog/hetzner-deploynix-the-best-price-to-performance-combo-for-laravel-in-europe" rel="noopener noreferrer"&gt;our Hetzner deep dive&lt;/a&gt;. The ARM line extends that logic one step further.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Matters for Laravel Performance?
&lt;/h2&gt;

&lt;p&gt;Here is the mental model that cuts through the benchmark noise: a PHP request is single-threaded, but a PHP server is parallel.&lt;/p&gt;

&lt;p&gt;When a request hits your Laravel app, nginx hands it to one PHP-FPM worker process. That worker executes your entire request on a single core: routing, middleware, Eloquent queries, view rendering, JSON serialization. PHP does not spread one request across multiple cores. So the latency of any individual request is bounded by single-thread performance, which is exactly where x86 keeps its edge.&lt;/p&gt;

&lt;p&gt;But your server is not running one worker. A well-tuned 8 GB server typically runs 20 to 40 PHP-FPM workers side by side, each handling its own request on whatever core is free. Throughput, meaning how many requests per second the whole box can sustain, is a parallel problem. That is the game ARM was built to win. If you haven't tuned your worker pool, our guide to &lt;a href="https://deploynix.io/blog/tuning-php-fpm-for-laravel-workers-memory-and-process-management" rel="noopener noreferrer"&gt;PHP-FPM worker and memory tuning&lt;/a&gt; is the place to start, because an untuned pool will mask any hardware difference entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where x86 Wins: Latency per Request
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Higher single-thread performance, typically 15 to 20% by synthetic measures and 20 to 30% in real PHP workloads (&lt;a href="https://www.bitdoze.com/arm-vs-x86-vps-server-benchmarks/" rel="noopener noreferrer"&gt;bitdoze benchmark&lt;/a&gt;). Every individual request completes faster, all else equal. Decades of compiler and library optimization targeting the architecture. Zero compatibility questions, ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Apps where the user experience hinges on a handful of heavy synchronous endpoints. Think report generation in the request cycle, complex Eloquent aggregations on the dashboard, or an API where a partner's SLA measures p95 latency in milliseconds. Also spiky, low-concurrency traffic: if you rarely have more than a few requests in flight, you cannot exploit parallelism, so per-core speed is all that counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; You pay roughly 26% more at Hetzner for the privilege, at the time of writing. If your slow endpoints are slow because of database queries or missing cache, and in our experience they usually are, a faster CPU buys you very little. Profile before you pay for silicon.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where ARM Wins: Throughput per Euro
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Better Stack pegs ARM at 20 to 40% better price-performance for parallel workloads (&lt;a href="https://betterstack.com/community/guides/web-servers/hetzner-cloud-review/" rel="noopener noreferrer"&gt;Better Stack&lt;/a&gt;). Steady concurrent traffic across many FPM workers is the textbook parallel workload. Lower power draw means providers can keep prices low sustainably rather than as a teaser. Queue workers, schedulers, and Horizon fleets are throughput machines with no human waiting on any single job, which makes them close to a perfect fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Steady multi-user traffic where dozens of requests overlap. Queue-heavy applications processing thousands of jobs per hour. Worker fleets, cron-heavy apps, webhook receivers, and background processing tiers where total jobs per hour matters and per-job latency does not. Budget-sensitive projects where 26% off the hosting bill funds something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Each individual request runs somewhat slower. On a typical Laravel page that spends most of its wall time waiting on MySQL and Valkey rather than burning CPU, the difference is often single-digit milliseconds and invisible. On a genuinely CPU-bound endpoint, it is measurable. You also take on a small compatibility checklist, covered below.&lt;/p&gt;

&lt;p&gt;The genuinely balanced take: neither architecture "wins Laravel." ARM wins Laravel throughput per euro; x86 wins Laravel latency per request. Which one you should optimize for is a question about your users, not your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is ARM Compatibility Still a Problem in 2026?
&lt;/h2&gt;

&lt;p&gt;For the standard Laravel stack, no. This was a real concern in 2021. It is mostly a solved problem now.&lt;/p&gt;

&lt;p&gt;Everything a typical production Laravel server runs has mature arm64 builds: nginx, PHP (every supported version), MySQL, MariaDB, PostgreSQL, Valkey, Redis, Supervisor, certbot, Node.js for asset builds, and Composer, which is architecture-agnostic PHP anyway. Ubuntu publishes arm64 images with identical package coverage for everything above. The popular ondrej PPA, the de facto standard source for PHP on Ubuntu, ships arm64 packages, so PHP installation is literally the same commands:&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.4-fpm php8.4-cli php8.4-mysql &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-redis php8.4-mbstring php8.4-xml php8.4-curl &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-zip php8.4-intl php8.4-gd php8.4-bcmath
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not one flag changes between architectures. Your &lt;code&gt;composer install&lt;/code&gt;, your &lt;code&gt;.env&lt;/code&gt;, your nginx vhost, and your Supervisor configs are all byte-identical. If you can tell which architecture you are on from inside a Laravel codebase, something unusual is going on.&lt;/p&gt;

&lt;p&gt;When you do want to check, two commands answer the question:&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;uname&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt;                    &lt;span class="c"&gt;# aarch64 on ARM, x86_64 on Intel/AMD&lt;/span&gt;
dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;   &lt;span class="c"&gt;# arm64 or amd64 (Debian/Ubuntu)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What to Actually Check Before Switching
&lt;/h3&gt;

&lt;p&gt;The remaining rough edges in 2026 cluster in three places, and you can audit all of them in an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Closed-source binaries.&lt;/strong&gt; Anything you download as a precompiled blob rather than install from a package manager deserves a look. Monitoring agents from smaller vendors, license-key-protected PHP tools, proprietary CLI utilities. Most major vendors ship arm64 builds now, but "most" is not "all." Check what you have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;file /usr/local/bin/your-vendor-tool
&lt;span class="c"&gt;# ELF 64-bit LSB executable, x86-64 ...  -&amp;gt; won't run on ARM&lt;/span&gt;
&lt;span class="c"&gt;# ELF 64-bit LSB executable, ARM aarch64 -&amp;gt; you're fine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PECL extensions that compile from source.&lt;/strong&gt; Common extensions like redis, imagick, xdebug, and swoole build cleanly on arm64. Obscure or unmaintained PECL packages occasionally have hand-written x86 assembly or outdated build scripts. If your &lt;code&gt;php -m&lt;/code&gt; output contains anything exotic, test-compile it on a throwaway ARM instance before committing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker images without arm64 manifests.&lt;/strong&gt; If part of your stack runs in containers, verify each image publishes an arm64 variant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker manifest inspect ghcr.io/vendor/some-image:latest &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-B1&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; &lt;span class="s1"&gt;'"architecture"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Official images for PHP, nginx, MySQL, PostgreSQL, and Valkey are all multi-arch. The gaps show up in small vendor images and internal tooling built by a CI runner that only knows amd64.&lt;/p&gt;

&lt;p&gt;Here's the twist we hinted at earlier: if your team develops on Apple Silicon Macs, you have been running your stack on ARM for years already. Every M-series MacBook running your app in Docker or Herd is an arm64 environment. The classic "works on my machine" architecture mismatch has quietly inverted. Deploying to an ARM server means production finally matches the laptops, and any arm64-incompatible dependency would have bitten your developers long before it bit production. The short version: M-series adoption did more for arm64 server viability than any vendor announcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Honest Are the Benchmarks?
&lt;/h2&gt;

&lt;p&gt;Benchmark methodology is where ARM-versus-x86 articles quietly mislead people, so let's be explicit about what different tests actually measure.&lt;/p&gt;

&lt;p&gt;Synthetic benchmarks like Geekbench multi-core flatter ARM. They saturate every core with math-heavy work, which is the exact scenario Ampere designed for. Real PHP applications spend most of their time in memory access, I/O waits, and branchy interpreted code, not vectorized math loops.&lt;/p&gt;

&lt;p&gt;Real-application benchmarks tell a more modest story. The &lt;a href="https://www.bitdoze.com/arm-vs-x86-vps-server-benchmarks/" rel="noopener noreferrer"&gt;bitdoze ARM vs x86 VPS benchmark&lt;/a&gt;, which tested WordPress-style PHP workloads on comparable instances, found x86 ahead by roughly 7 to 10% in raw throughput and 20 to 30% in single-thread performance. WordPress is a reasonable stand-in for PHP frameworks generally: interpreted PHP, MySQL queries, template rendering. Note what happened there: x86 won raw throughput too, just narrowly.&lt;/p&gt;

&lt;p&gt;So why do we still say ARM wins throughput? Because the benchmark compares performance at equal specs, not at equal cost. If x86 delivers 7 to 10% more requests per second but costs 26% more, ARM delivers meaningfully more requests per euro. That is the number your hosting bill cares about. Better Stack's 20 to 40% price-performance advantage figure (&lt;a href="https://betterstack.com/community/guides/web-servers/hetzner-cloud-review/" rel="noopener noreferrer"&gt;Better Stack&lt;/a&gt;) is exactly this arithmetic applied across their test suite.&lt;/p&gt;

&lt;p&gt;The other honesty check: benchmark your app, not someone else's. A Livewire dashboard, a JSON API, and a queue fleet have wildly different CPU profiles. Our methodology from &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 Laravel on a $5 server&lt;/a&gt; applies unchanged here: same codebase, same seed data, &lt;code&gt;wrk&lt;/code&gt; or &lt;code&gt;k6&lt;/code&gt; against realistic endpoints, and watch p95 rather than averages. Run it against one ARM and one x86 instance for an hour each. The two servers together cost less than a pizza, and you will have real numbers instead of ours or anyone else's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Workloads Fit ARM, and Which Don't?
&lt;/h2&gt;

&lt;p&gt;Here is the decision table we walk through when someone asks us whether to provision CAX or CPX. It compresses everything above into workload shapes.&lt;/p&gt;

&lt;p&gt;Workload&lt;/p&gt;

&lt;p&gt;Better Fit&lt;/p&gt;

&lt;p&gt;Why&lt;/p&gt;

&lt;p&gt;Steady web traffic, many concurrent users&lt;/p&gt;

&lt;p&gt;ARM&lt;/p&gt;

&lt;p&gt;Parallel across FPM workers; per-request latency delta is invisible behind I/O waits&lt;/p&gt;

&lt;p&gt;Queue workers / Horizon fleets&lt;/p&gt;

&lt;p&gt;ARM&lt;/p&gt;

&lt;p&gt;Pure throughput, nobody waits on an individual job; best price per job processed&lt;/p&gt;

&lt;p&gt;Scheduled jobs, webhook receivers, cron-heavy apps&lt;/p&gt;

&lt;p&gt;ARM&lt;/p&gt;

&lt;p&gt;Background throughput, latency irrelevant&lt;/p&gt;

&lt;p&gt;Staging, preview, and QA environments&lt;/p&gt;

&lt;p&gt;ARM&lt;/p&gt;

&lt;p&gt;Cost matters, performance barely does; also validates arm64 before production&lt;/p&gt;

&lt;p&gt;Dedicated database server&lt;/p&gt;

&lt;p&gt;Either&lt;/p&gt;

&lt;p&gt;Databases are memory- and disk-bound first; buy RAM and fast storage, not architecture&lt;/p&gt;

&lt;p&gt;Latency-critical API with strict p95 SLA&lt;/p&gt;

&lt;p&gt;x86&lt;/p&gt;

&lt;p&gt;Single-thread speed directly shortens every request&lt;/p&gt;

&lt;p&gt;Spiky, low-concurrency traffic&lt;/p&gt;

&lt;p&gt;x86&lt;/p&gt;

&lt;p&gt;Few overlapping requests means no parallelism to exploit; per-core speed is everything&lt;/p&gt;

&lt;p&gt;Heavy synchronous CPU work (PDF generation, image processing in-request)&lt;/p&gt;

&lt;p&gt;x86&lt;/p&gt;

&lt;p&gt;CPU-bound single requests feel the 20-30% single-thread gap directly&lt;/p&gt;

&lt;p&gt;Anything depending on x86-only closed-source binaries&lt;/p&gt;

&lt;p&gt;x86&lt;/p&gt;

&lt;p&gt;Compatibility is binary; no discount fixes a tool that won't run&lt;/p&gt;

&lt;p&gt;Two patterns fall out of that table. First, the further a workload sits from a waiting human, the better ARM fits. Second, mixed fleets are legitimate: plenty of teams run ARM queue and web tiers behind an x86 box that handles the one CPU-heavy synchronous service. Nothing forces a single architecture across your infrastructure, and if you're still choosing where that infrastructure lives, our &lt;a href="https://deploynix.io/blog/the-laravel-developers-guide-to-choosing-a-cloud-provider-in-2026" rel="noopener noreferrer"&gt;cloud provider guide for Laravel developers&lt;/a&gt; maps which providers offer ARM at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running ARM Servers on Deploynix
&lt;/h2&gt;

&lt;p&gt;Since we get asked: yes, Deploynix provisions Hetzner's ARM CAX instances the same way it provisions any other server. You pick a CAX plan during server creation instead of a CX or CPX plan, and that is the entire difference in the workflow.&lt;/p&gt;

&lt;p&gt;Under the hood, the provisioning stack detects the architecture and installs the identical stack from arm64 package sources: nginx, PHP-FPM (any supported version, or several side by side), MySQL, MariaDB or PostgreSQL, Valkey, and Supervisor. Firewall rules, deploy keys, SSL, zero-downtime deployment structure, all identical. Your deploy script does not know or care that it is running on Ampere cores. The same applies across providers: Hetzner, DigitalOcean, Vultr, Linode, AWS, and custom servers all land on the same stack layout, so an ARM box slots into an existing fleet without special handling.&lt;/p&gt;

&lt;p&gt;The genuinely useful part for this article's question is monitoring. Deploynix tracks CPU, memory, and load metrics per server, which means you can run the before-and-after comparison with real data: note your x86 server's CPU utilization and load average under normal traffic, stand up the ARM twin, shift traffic, and compare the same charts a week later. In our experience that graph settles the ARM debate faster than any benchmark blog post, ours included.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Migrate a Laravel App from x86 to ARM?
&lt;/h2&gt;

&lt;p&gt;You don't. You migrate to a new server that happens to be ARM. This distinction matters: there is no in-place architecture switch. Every binary on the machine is compiled for one instruction set, so the move is a reprovision, and that is actually good news, because reprovisioning is a well-understood, low-risk operation with a built-in rollback.&lt;/p&gt;

&lt;p&gt;The shape is classic blue/green at the server level:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Provision the ARM server alongside the old one.&lt;/strong&gt; New CAX instance, same region, same stack configuration. Your existing x86 server keeps serving traffic untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Deploy the same release.&lt;/strong&gt; Same repository, same branch, same &lt;code&gt;.env&lt;/code&gt; values pointed at the same database (or a replica if you are moving the database too). Run &lt;code&gt;composer install&lt;/code&gt; and your asset build on the new box; both are architecture-transparent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Test against the server's IP directly.&lt;/strong&gt; Hit the ARM server with your smoke tests and a short load test before any real user does. Edit your local &lt;code&gt;/etc/hosts&lt;/code&gt; to point your domain at the new IP for full end-to-end checks. This is where any exotic PECL extension or x86-only binary would surface, safely, with zero user impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Cut over gradually or atomically.&lt;/strong&gt; The simple path is a DNS cutover: drop your TTL to 60 seconds a day in advance, then repoint the A record. The cautious path is a load balancer in front of both servers, shifting traffic in stages: 10%, then 50%, then 100%, while you watch error rates and p95 latency. Deploynix's load balancer server type exists for exactly this kind of gradual shift, and it turns an architecture migration into a routing decision you can reverse in seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Keep the x86 server warm for a week.&lt;/strong&gt; It costs a few euros and it is the cheapest insurance you will ever buy. If anything subtle appears, cut DNS back. After a clean week, destroy it and pocket the ongoing savings.&lt;/p&gt;

&lt;p&gt;Queue workers are even easier because they need no cutover at all. Point ARM workers at the same Redis or database queue, let both architectures consume jobs side by side, then retire the x86 workers. The queue does not care what processed the job. If you run Horizon, drain the old supervisor gracefully with &lt;code&gt;horizon:terminate&lt;/code&gt; and let the ARM fleet absorb the backlog.&lt;/p&gt;

&lt;p&gt;Total effort for a typical single-app setup: an afternoon of work spread across a week of caution. Compare that with the effort you'd spend shaving 26% off your hosting bill any other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Not Bother?
&lt;/h2&gt;

&lt;p&gt;Cost optimization has a floor, and honesty demands we mark it.&lt;/p&gt;

&lt;p&gt;Skip the switch if your total hosting spend is one small server. Saving €5.50 a month, at the time of writing, does not justify even an afternoon of migration work unless you are doing it for the learning. At that scale your bigger cost question is whether a VPS is right at all, which we tackled in &lt;a href="https://deploynix.io/blog/laravel-clouds-5-plan-vs-a-5-vps-the-real-cost-math" rel="noopener noreferrer"&gt;the real cost math of Laravel Cloud's $5 plan vs a $5 VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Skip it if you depend on an x86-only binary you cannot replace. A monitoring agent, a licensed PDF engine, a vendor CLI. One hard incompatibility outweighs any discount, and emulation layers like qemu on a production server are a support ticket generator, not a solution.&lt;/p&gt;

&lt;p&gt;Skip it if your p95 latency is already tight against an SLA and your endpoints are CPU-bound. A 20 to 30% single-thread deficit (&lt;a href="https://www.bitdoze.com/arm-vs-x86-vps-server-benchmarks/" rel="noopener noreferrer"&gt;bitdoze&lt;/a&gt;) lands directly on exactly those requests. Buy the faster core; it is the correct tool.&lt;/p&gt;

&lt;p&gt;And skip it, for now, if your provider's ARM availability is thin in your region. Hetzner's CAX line is not available in every location, and a cheaper server in the wrong region can cost you more in latency than it saves in euros. Check availability before you plan anything.&lt;/p&gt;

&lt;p&gt;Everyone else, which in our experience is the majority of Laravel apps (steady traffic, I/O-bound endpoints, queue-heavy backends), is leaving 26% on the table out of inertia.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does Laravel officially support ARM servers?
&lt;/h3&gt;

&lt;p&gt;Laravel doesn't target an architecture at all; it targets PHP. PHP has shipped first-class arm64 builds for years, and every part of the standard stack (nginx, MySQL/PostgreSQL, Valkey, Supervisor) has mature arm64 packages. If PHP runs, Laravel runs. There is no ARM-specific configuration anywhere in a Laravel codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will my Composer dependencies work on ARM?
&lt;/h3&gt;

&lt;p&gt;Almost certainly. Composer packages are PHP source code, which is architecture-independent. The only risk lives in compiled PHP extensions (PECL) and any vendored native binaries. Check &lt;code&gt;php -m&lt;/code&gt; for exotic extensions and &lt;code&gt;file&lt;/code&gt; any bundled binaries. Mainstream extensions like redis, imagick, gd, and intl all build cleanly on arm64 in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is ARM actually faster than x86 for Laravel?
&lt;/h3&gt;

&lt;p&gt;Per request, no: x86 completes a single request roughly 20 to 30% faster in real PHP benchmarks (&lt;a href="https://www.bitdoze.com/arm-vs-x86-vps-server-benchmarks/" rel="noopener noreferrer"&gt;bitdoze&lt;/a&gt;). Per euro, yes: at Hetzner's roughly 26% lower ARM pricing at the time of writing, ARM delivers more total throughput for the same spend on concurrent traffic and queue work (&lt;a href="https://betterstack.com/community/guides/web-servers/hetzner-cloud-review/" rel="noopener noreferrer"&gt;Better Stack&lt;/a&gt;). Pick the metric that matches your bottleneck.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I mix ARM and x86 servers in one setup?
&lt;/h3&gt;

&lt;p&gt;Yes, and it is often the best answer. Architectures share nothing at the HTTP or queue layer, so an ARM queue fleet can consume the same Redis queue as an x86 web tier, and a load balancer can split traffic across both. Many teams run ARM for throughput tiers and keep one x86 box for a CPU-bound service.&lt;/p&gt;

&lt;h3&gt;
  
  
  How risky is the migration?
&lt;/h3&gt;

&lt;p&gt;Low, if you treat it as a blue/green server swap rather than an upgrade. You provision the ARM server alongside the old one, deploy the same release, test against the IP, shift traffic gradually, and keep the old server for a week as rollback. At no point is your app running on only an untested box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;ARM in 2026 is not a gamble; it is a pricing decision with a known trade-off. You accept 15 to 30% slower single-thread execution in exchange for roughly 26% lower cost at the time of writing, and because PHP-FPM's concurrency model spreads real traffic across many parallel workers, most Laravel workloads convert that trade into more throughput per euro, not less. The exceptions are real (latency-critical endpoints, CPU-bound synchronous work, x86-only binaries) but they are exceptions, and they are identifiable in an afternoon of auditing.&lt;/p&gt;

&lt;p&gt;Your next step is cheap and concrete: provision one ARM instance, deploy your actual application to it, and load test it against your current server for a week. A CAX31 costs €15.99 a month at the time of writing, so the experiment costs a few euros and answers the question with your data instead of ours. Once it is up, wire it into your stack the standard way with 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; and let the monitoring graphs make the call.&lt;/p&gt;

</description>
      <category>vps</category>
      <category>costoptimization</category>
      <category>hetzner</category>
      <category>laravel</category>
    </item>
    <item>
      <title>Migrating From Heroku to Deploynix: A Cost and Control Upgrade for Laravel</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Fri, 21 Aug 2026 11:13:03 +0000</pubDate>
      <link>https://dev.to/deploynix/migrating-from-heroku-to-deploynix-a-cost-and-control-upgrade-for-laravel-375c</link>
      <guid>https://dev.to/deploynix/migrating-from-heroku-to-deploynix-a-cost-and-control-upgrade-for-laravel-375c</guid>
      <description>&lt;p&gt;There's a specific moment when a Heroku invoice stops making sense. For most teams it isn't the first month, or even the first year. It's the month you scale a dyno to handle real traffic, add a worker for your queue, bump Postgres off the hobby tier, and realize you're paying north of $100 every month for compute that a single mid-range VPS would handle without breaking a sweat. You stare at the line items, do the mental math, and the number just doesn't add up anymore.&lt;/p&gt;

&lt;p&gt;We've heard this story from Laravel developers repeatedly over the past two years, and the data backs it up. In a Barclays CIO survey, 83% of enterprises said they plan to repatriate at least some workloads from public cloud back to infrastructure they control (&lt;a href="https://northflank.com/blog/cloud-repatriation" rel="noopener noreferrer"&gt;Northflank&lt;/a&gt;, 2024). Organizations that make the move strategically report infrastructure savings of 30-60% (&lt;a href="https://massivegrid.com/blog/cloud-repatriation-2026-moving-off-aws-azure-gcp/" rel="noopener noreferrer"&gt;MassiveGRID&lt;/a&gt;, 2026). The pendulum that swung hard toward "never touch a server" is swinging back toward "own your stack, but automate the painful parts."&lt;/p&gt;

&lt;p&gt;To be clear: Heroku earned its reputation. &lt;code&gt;git push heroku main&lt;/code&gt; was a genuine revolution in 2010, and the developer experience is still excellent in 2026. The problem was never the product. The problem is the price you pay for that experience once your app is no longer a side project, and the ceiling you hit when you need control Heroku doesn't offer.&lt;/p&gt;

&lt;p&gt;This guide is the complete migration path for a production Laravel application: why teams leave, how every Heroku concept maps to a VPS, a pre-migration audit checklist, a step-by-step runbook with real commands, a worked cost comparison, and an honest accounting of what you take on when you leave a fully managed platform.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- A typical production Heroku stack (web dyno, worker dyno, Postgres, Redis) runs about $115/month; a comparable VPS costs $12-24/month. - 83% of enterprises plan to repatriate cloud workloads (Northflank, 2024). - Heroku uses PostgreSQL, and Deploynix supports PostgreSQL natively, so no database engine switch is required. - A parallel-running window plus a low-TTL DNS cutover makes the migration effectively zero-downtime.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why Are Teams Leaving Heroku in 2026?
&lt;/h2&gt;

&lt;p&gt;Cost at scale is the headline reason, and it's substantial. Strategic cloud repatriation delivers 30-60% infrastructure savings on average (&lt;a href="https://massivegrid.com/blog/cloud-repatriation-2026-moving-off-aws-azure-gcp/" rel="noopener noreferrer"&gt;MassiveGRID&lt;/a&gt;, 2026), and Heroku migrations often land at the top of that range because dyno pricing carries the platform's largest margins. But cost is only the most visible of four recurring complaints.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cost Curve Bends the Wrong Way
&lt;/h3&gt;

&lt;p&gt;Heroku's pricing is friendly at the bottom and punishing at the top. At the time of writing, a Basic dyno runs about $7/month, a Standard-1X about $25/month, and Performance dynos start at $250/month and climb from there. Managed Postgres and the Key-Value Store (Redis-compatible) are priced separately, each with their own tier ladder.&lt;/p&gt;

&lt;p&gt;The trouble is that these tiers don't scale with hardware prices. VPS compute has gotten dramatically cheaper per gigabyte of RAM over the past decade. Heroku's dyno pricing hasn't followed. A Standard-1X dyno gives you 512MB of RAM for $25/month. A $9/month VPS from Hetzner gives you 4GB. That's roughly a 20x difference in price per gigabyte, and it compounds across every dyno and addon you run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dyno Constraints
&lt;/h3&gt;

&lt;p&gt;Dynos restart daily, whether you want them to or not. The ephemeral filesystem means anything written to disk vanishes on restart, which forces you onto S3 for every uploaded file even when local storage would be fine. Memory limits are hard walls: exceed 512MB on a Standard-1X and you get R14 errors and degraded performance until you pay for the next tier. None of these constraints exist on a server you control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Addon Sprawl
&lt;/h3&gt;

&lt;p&gt;Every capability is a separate line item. Postgres is an addon. Redis is an addon. Log retention beyond 1,500 lines is an addon. Scheduled jobs, monitoring, error tracking: addons. Each one is individually reasonable and collectively expensive. Teams routinely discover their addon spend exceeds their dyno spend, and that a lot of it duplicates things Laravel and a VPS give you for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  EU Data Residency
&lt;/h3&gt;

&lt;p&gt;Heroku's Common Runtime offers US and EU regions, but fine-grained control over where your data physically lives requires Private Spaces, which start at enterprise-level pricing. For teams with GDPR obligations or customers who ask pointed questions about data location, "our database is on a Hetzner server in Falkenstein, Germany" is a much easier answer than a diagram of Heroku's addon infrastructure. This is a pattern we cover across the whole PaaS category in &lt;a href="https://deploynix.io/blog/why-laravel-developers-are-leaving-generic-paas-for-laravel-specific-platforms" rel="noopener noreferrer"&gt;why Laravel developers are leaving generic PaaS platforms&lt;/a&gt;, and it applies to Heroku more than most.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do Heroku Concepts Map to a VPS?
&lt;/h2&gt;

&lt;p&gt;Every Heroku primitive has a direct equivalent on a Deploynix-managed server, which is what makes this migration mechanical rather than architectural. Nothing in your Laravel codebase needs to change. The table below is the mental model for the entire migration; the rest of this guide is just executing it row by row.&lt;/p&gt;

&lt;p&gt;Heroku concept&lt;/p&gt;

&lt;p&gt;Deploynix / VPS equivalent&lt;/p&gt;

&lt;p&gt;Dyno&lt;/p&gt;

&lt;p&gt;Server + site (Nginx + PHP-FPM)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Procfile&lt;/code&gt; entries (&lt;code&gt;web:&lt;/code&gt;, &lt;code&gt;worker:&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Supervisor-managed daemons and queue workers, configured in the UI&lt;/p&gt;

&lt;p&gt;Config vars&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; file, edited through the dashboard&lt;/p&gt;

&lt;p&gt;Release phase (&lt;code&gt;release: php artisan migrate&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Deploy hooks that run during each deployment&lt;/p&gt;

&lt;p&gt;Heroku Postgres addon&lt;/p&gt;

&lt;p&gt;Self-hosted PostgreSQL on your server&lt;/p&gt;

&lt;p&gt;Heroku Key-Value Store (Redis)&lt;/p&gt;

&lt;p&gt;Valkey (Redis-compatible), installed by default&lt;/p&gt;

&lt;p&gt;Heroku Scheduler addon&lt;/p&gt;

&lt;p&gt;Laravel scheduler via cron (&lt;code&gt;schedule:run&lt;/code&gt; every minute)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;heroku ps&lt;/code&gt; / dyno metrics&lt;/p&gt;

&lt;p&gt;Server monitoring with CPU, RAM, and disk alerts&lt;/p&gt;

&lt;p&gt;&lt;code&gt;heroku releases:rollback&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;One-click instant rollback to a previous release&lt;/p&gt;

&lt;p&gt;Automatic SSL&lt;/p&gt;

&lt;p&gt;Free Let's Encrypt SSL, auto-renewed&lt;/p&gt;

&lt;p&gt;Two rows deserve emphasis. First, the database: Heroku standardized on PostgreSQL, and Deploynix provisions PostgreSQL alongside MySQL and MariaDB, so there's no forced engine migration. Your &lt;code&gt;pg_dump&lt;/code&gt; restores directly. Second, the release phase: Heroku's &lt;code&gt;release:&lt;/code&gt; command maps one-to-one onto deploy hooks, so your migration-on-deploy workflow survives intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heroku vs. Deploynix: An Honest Comparison
&lt;/h2&gt;

&lt;p&gt;Before the runbook, a fair assessment of both platforms. We build Deploynix, so discount our bias accordingly, but we've tried to be straight about where Heroku genuinely wins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Heroku
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; The best-polished PaaS developer experience in the industry, fifteen years of refinement, and truly zero server operations. You never think about the OS, patching, or capacity in units smaller than a dyno. The addon marketplace makes bolting on a service a one-line command. The buildpack system means deployment "just works" for almost any stack with no configuration at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Prototypes, internal tools, and early-stage products where engineering time is worth far more than infrastructure spend. Teams with zero appetite for operations, even automated operations. Apps whose traffic genuinely justifies elastic dyno scaling on unpredictable schedules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; The cost curve steepens sharply with scale, and you pay the managed-platform premium on every component: compute, database, cache, logging, scheduling. The 512MB memory ceiling on Standard-1X dynos is tight for a modern Laravel app with Octane or heavy queue jobs. The ephemeral filesystem forces architectural decisions on you. Fine-grained data residency requires Private Spaces pricing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploynix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; You run on servers you own, at VPS prices, with the operational work automated. Provisioning on DigitalOcean, Vultr, Linode, Hetzner, AWS, or any custom VPS takes minutes. Zero-downtime deploys, instant rollback, deploy hooks, queue workers and daemons via Supervisor, the Laravel scheduler, free SSL, monitoring with alerts, and automated database backups to S3-compatible storage are all built in. Security hardening is applied by default at provision time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Production Laravel applications with steady traffic, teams that want a 60-90% infrastructure cost reduction without giving up push-to-deploy workflows, and anyone who needs to choose exactly where their data lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; A VPS is your responsibility in ways a dyno is not. Deploynix automates OS hardening, unattended security upgrades, backups, and monitoring, but you are still the one choosing when to scale the server, and vertical scaling means a resize rather than a slider. If your traffic is genuinely spiky by 10x on unpredictable schedules, elastic dyno scaling still has a real argument. We compare that trade-off across providers in our guide on &lt;a href="https://deploynix.io/blog/migrating-laravel-from-railway-render-or-flyio-to-a-vps" rel="noopener noreferrer"&gt;migrating Laravel from Railway, Render, or &lt;/a&gt;&lt;a href="http://Fly.io" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;&lt;a href="https://deploynix.io/blog/migrating-laravel-from-railway-render-or-flyio-to-a-vps" rel="noopener noreferrer"&gt; to a VPS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pre-Migration Audit: What to Inventory Before You Move
&lt;/h2&gt;

&lt;p&gt;A clean migration starts with a complete inventory, and in our experience the audit takes under an hour for a typical app. Teams that skip it are the ones that discover a forgotten Heroku Scheduler job or an addon-injected config var two days after cutover. Work through these four checks before you provision anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Buildpacks and Procfile
&lt;/h3&gt;

&lt;p&gt;List what Heroku is actually running for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku buildpacks &lt;span class="nt"&gt;-a&lt;/span&gt; your-app
&lt;span class="nb"&gt;cat &lt;/span&gt;Procfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical Laravel app shows the PHP buildpack (often with Node.js for asset builds) and a Procfile like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;web: heroku-php-nginx -C nginx.conf public/
worker: php artisan queue:work redis --tries=3 --timeout=90
release: php artisan migrate --force
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line becomes a row in your migration plan: &lt;code&gt;web:&lt;/code&gt; becomes the Nginx site Deploynix configures automatically, &lt;code&gt;worker:&lt;/code&gt; becomes a queue worker defined in the UI, and &lt;code&gt;release:&lt;/code&gt; becomes a deploy hook. If you have custom buildpacks (wkhtmltopdf, ImageMagick, custom PHP extensions), note them; on a VPS these are one-time &lt;code&gt;apt&lt;/code&gt; installs rather than buildpack gymnastics.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Addon Inventory
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku addons &lt;span class="nt"&gt;-a&lt;/span&gt; your-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each addon, decide: self-host it, replace it, or keep it as an external SaaS. Postgres and the Key-Value Store move onto your server. Logging addons are replaced by real log files on disk plus Laravel's own channels. Scheduler is replaced by cron. Error trackers like Sentry or Bugsnag are ordinary SaaS products that work identically from a VPS; keep them and just carry the DSN over.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Export Your Config Vars
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku config &lt;span class="nt"&gt;-a&lt;/span&gt; your-app &lt;span class="nt"&gt;--shell&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; heroku-config.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces &lt;code&gt;KEY=value&lt;/code&gt; lines ready to merge into your new &lt;code&gt;.env&lt;/code&gt;. Watch for two gotchas. First, addon-injected vars like &lt;code&gt;DATABASE_URL&lt;/code&gt; and &lt;code&gt;REDIS_URL&lt;/code&gt; will change; don't copy them blindly. Second, Heroku's PHP buildpack reads &lt;code&gt;DATABASE_URL&lt;/code&gt; as a single URL, while a conventional Laravel &lt;code&gt;.env&lt;/code&gt; splits it into &lt;code&gt;DB_HOST&lt;/code&gt;, &lt;code&gt;DB_PORT&lt;/code&gt;, &lt;code&gt;DB_DATABASE&lt;/code&gt;, &lt;code&gt;DB_USERNAME&lt;/code&gt;, and &lt;code&gt;DB_PASSWORD&lt;/code&gt;. Translate accordingly, or keep using &lt;code&gt;DATABASE_URL&lt;/code&gt; since Laravel supports it natively.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The Database Decision (Spoiler: There Isn't One)
&lt;/h3&gt;

&lt;p&gt;Heroku means PostgreSQL. Some VPS panels are MySQL-only, which turns a hosting migration into a database engine migration, an order of magnitude more risk. Deploynix provisions PostgreSQL as a first-class option alongside MySQL and MariaDB, so you keep your engine, your extensions, and your query behavior. The only decision left is which Postgres major version to install; match your Heroku version, which you can check with &lt;code&gt;heroku pg:info -a your-app&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Runbook, Step by Step
&lt;/h2&gt;

&lt;p&gt;The full migration takes an afternoon of focused work plus a parallel-running window, and no step involves downtime until the final DNS cutover. We'll assume a standard Laravel app with Postgres, Redis-backed queues, and scheduled jobs. Here's the sequence we run ourselves.&lt;/p&gt;

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

&lt;p&gt;In Deploynix, connect your cloud provider (DigitalOcean, Vultr, Linode, Hetzner, AWS) or add a custom VPS by IP, then provision. For the worked example later in this post, a 2 vCPU / 4GB server is comfortable. Choose PostgreSQL as the database engine during provisioning; Valkey is installed for cache and queues. Provisioning applies security hardening automatically: firewall rules, SSH lockdown, fail2ban, and unattended security upgrades.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Move the Database
&lt;/h3&gt;

&lt;p&gt;Capture a fresh backup on Heroku, download it, and restore it to the new server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku pg:backups:capture &lt;span class="nt"&gt;-a&lt;/span&gt; your-app
heroku pg:backups:download &lt;span class="nt"&gt;-a&lt;/span&gt; your-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you &lt;code&gt;latest.dump&lt;/code&gt; in Postgres custom format. Restore it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_restore &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; your-server-ip &lt;span class="nt"&gt;-U&lt;/span&gt; deploy &lt;span class="nt"&gt;-d&lt;/span&gt; your_app_db latest.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--no-acl --no-owner&lt;/code&gt; flags matter: Heroku's dump references Heroku-internal roles that don't exist on your server, and these flags strip them. For a database of a few gigabytes this completes in minutes. This first restore is a rehearsal; you'll do it once more, fresh, right before cutover.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Create the Site and Connect GitHub
&lt;/h3&gt;

&lt;p&gt;Add a site on the server, point it at your GitHub repository (GitLab and Bitbucket work the same way), and enable auto-deploy on push to your production branch. Set your environment variables from the audited &lt;code&gt;heroku-config.env&lt;/code&gt;, with the database and Redis vars updated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=your_app_db
DB_USERNAME=deploy
DB_PASSWORD=generated-at-provision

REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1

QUEUE_CONNECTION=redis
CACHE_STORE=redis
SESSION_DRIVER=redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then recreate your release phase as deploy hooks, which run on every deployment:&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;
php artisan config:cache
php artisan route:cache
php artisan view:cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploys are zero-downtime: each push builds a new release directory and switches a symlink only when everything succeeds, so a failed build never takes the site down. Trigger a first deploy and confirm the app boots against the restored database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Queue Workers and the Scheduler
&lt;/h3&gt;

&lt;p&gt;Recreate each &lt;code&gt;worker:&lt;/code&gt; Procfile line as a queue worker in the Deploynix UI, which writes and manages the Supervisor configuration for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Command: queue:work
Connection: redis
Tries: 3
Timeout: 90
Processes: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supervisor restarts workers if they crash and Deploynix restarts them gracefully on each deploy, replacing the dyno restart behavior you relied on. For Heroku Scheduler jobs, the fix is nicer than what you had: Heroku Scheduler's 10-minute minimum granularity goes away. Enable the Laravel scheduler and every &lt;code&gt;schedule()&lt;/code&gt; definition in your app runs on its native cadence via a single cron entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* * * * * php /home/deploy/your-app/current/artisan schedule:run &amp;gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any Scheduler jobs lived only in the Heroku dashboard rather than in code, move them into &lt;code&gt;routes/console.php&lt;/code&gt; now. Your scheduling belongs in version control anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The Parallel-Running Window
&lt;/h3&gt;

&lt;p&gt;Don't cut over the day you finish setup. Run both environments in parallel for three to seven days. Point a test subdomain at the new server, enable the free SSL certificate, and exercise the app: logins, uploads, queue jobs, scheduled tasks, webhooks. Watch the built-in monitoring for memory and CPU headroom. Keep deploying to both targets during this window so the codebases never diverge.&lt;/p&gt;

&lt;p&gt;One rule during parallel running: the Heroku database remains the source of truth. The new server's database is a stale copy used for verification only. Any writes made during testing on the new stack are throwaway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: DNS Cutover With a Low TTL
&lt;/h3&gt;

&lt;p&gt;At least 24 hours before cutover, drop your DNS TTL to 300 seconds so the switch propagates in minutes rather than hours. At cutover time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku maintenance:on &lt;span class="nt"&gt;-a&lt;/span&gt; your-app

heroku pg:backups:capture &lt;span class="nt"&gt;-a&lt;/span&gt; your-app
heroku pg:backups:download &lt;span class="nt"&gt;-a&lt;/span&gt; your-app
pg_restore &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; your-server-ip &lt;span class="nt"&gt;-U&lt;/span&gt; deploy &lt;span class="nt"&gt;-d&lt;/span&gt; your_app_db latest.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then flip your DNS A record to the new server, confirm SSL issues for the apex domain, and verify propagation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short yourapp.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total write-downtime is the maintenance window: the duration of one final dump and restore, typically 5-15 minutes for a moderately sized database. Read-only traffic could even be served throughout if you skip maintenance mode and accept a small window of writes to reconcile.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Rollback Plan
&lt;/h3&gt;

&lt;p&gt;Keep the Heroku app scaled up but idle for two weeks. If anything goes seriously wrong, rollback is the cutover in reverse: dump the new database, restore to Heroku, turn maintenance off, flip DNS back. Because your TTL is still 300 seconds, the world follows within minutes. Only after two quiet weeks do you run the satisfying final commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku ps:scale &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;worker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;-a&lt;/span&gt; your-app
heroku addons:destroy heroku-postgresql &lt;span class="nt"&gt;-a&lt;/span&gt; your-app &lt;span class="nt"&gt;--confirm&lt;/span&gt; your-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Does the Move Actually Save? A Worked Example
&lt;/h2&gt;

&lt;p&gt;For a typical production Laravel app, the same workload that costs about $115/month on Heroku runs on a $12-24/month VPS, a saving of 79-90% on infrastructure. That's consistent with the 30-60% average that repatriating organizations report (&lt;a href="https://massivegrid.com/blog/cloud-repatriation-2026-moving-off-aws-azure-gcp/" rel="noopener noreferrer"&gt;MassiveGRID&lt;/a&gt;, 2026); Heroku migrations land above the average because every component carries the managed premium. Here are the numbers, using Heroku's pricing at the time of writing:&lt;/p&gt;

&lt;p&gt;Component&lt;/p&gt;

&lt;p&gt;Heroku&lt;/p&gt;

&lt;p&gt;VPS (Deploynix-managed)&lt;/p&gt;

&lt;p&gt;Web serving&lt;/p&gt;

&lt;p&gt;Standard-1X dyno, 512MB RAM: $25/mo&lt;/p&gt;

&lt;p&gt;Included on server&lt;/p&gt;

&lt;p&gt;Queue worker&lt;/p&gt;

&lt;p&gt;Standard-1X worker dyno: $25/mo&lt;/p&gt;

&lt;p&gt;Supervisor worker, included&lt;/p&gt;

&lt;p&gt;PostgreSQL&lt;/p&gt;

&lt;p&gt;Standard-0 addon: ~$50/mo&lt;/p&gt;

&lt;p&gt;Self-hosted, included&lt;/p&gt;

&lt;p&gt;Redis / cache&lt;/p&gt;

&lt;p&gt;Key-Value Store Premium-0: ~$15/mo&lt;/p&gt;

&lt;p&gt;Valkey, included&lt;/p&gt;

&lt;p&gt;Scheduled jobs&lt;/p&gt;

&lt;p&gt;Scheduler addon: free, 10-min granularity&lt;/p&gt;

&lt;p&gt;Cron, per-minute, included&lt;/p&gt;

&lt;p&gt;SSL&lt;/p&gt;

&lt;p&gt;Included&lt;/p&gt;

&lt;p&gt;Included (Let's Encrypt)&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;~$115/mo&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;$12-24/mo&lt;/strong&gt; (e.g. Hetzner 4GB ~$12, DigitalOcean 4GB ~$24)&lt;/p&gt;

&lt;p&gt;And the hardware comparison is lopsided in the VPS's favor. That $115 Heroku stack gives your app two 512MB containers and a shared database plan. The $12 Hetzner server gives you 4GB of RAM and 2 dedicated-share vCPUs for the app, database, and cache combined, with room to spare. Most apps migrate onto a smaller server than they expect.&lt;/p&gt;

&lt;p&gt;Two honest caveats. Add your Deploynix subscription to the right-hand column; even so, the total stays a fraction of the Heroku bill, and the platform fee is flat rather than scaling per-dyno. And if your app needs Performance dynos ($250+ each at the time of writing), your savings are far larger than this example, which is exactly the dynamic we walk through in &lt;a href="https://deploynix.io/blog/cloud-repatriation-for-laravel-from-aws-to-a-10-vps" rel="noopener noreferrer"&gt;cloud repatriation for Laravel: from AWS to a $10 VPS&lt;/a&gt;. For the small end of the spectrum, &lt;a href="https://deploynix.io/blog/laravel-clouds-5-plan-vs-a-5-vps-the-real-cost-math" rel="noopener noreferrer"&gt;the real cost math on a $5 VPS&lt;/a&gt; breaks down the same comparison at hobby scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Do You Take On When You Leave Heroku?
&lt;/h2&gt;

&lt;p&gt;Leaving a fully managed platform means accepting responsibility for three things Heroku handled invisibly, and the honest answer is that automation covers most, not all, of them. Here's the real operational delta, item by item, and how much of it is mitigated.&lt;/p&gt;

&lt;h3&gt;
  
  
  OS Updates and Security
&lt;/h3&gt;

&lt;p&gt;On Heroku, the stack image is patched for you. On a VPS, the OS is yours. Deploynix narrows this gap substantially: provisioning applies hardening by default (firewall, SSH configuration, fail2ban) and enables unattended security upgrades, so CVE patches land without your involvement. What remains yours is the occasional major distribution upgrade every couple of years. That's a real task, but it's a scheduled afternoon, not a lurking daily burden.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backups
&lt;/h3&gt;

&lt;p&gt;Heroku Postgres continuous protection was genuinely good, and this is the responsibility to take most seriously. The mitigation: automated database backups to any S3-compatible storage (Backblaze B2, Wasabi, Cloudflare R2, DigitalOcean Spaces) on a schedule you define, with retention policies. Configure this on day one, before cutover, and restore-test it once. We wrote a full setup guide in &lt;a href="https://deploynix.io/blog/automated-database-backups-set-it-forget-it-sleep-well" rel="noopener noreferrer"&gt;automated database backups: set it, forget it, sleep well&lt;/a&gt;. Off-server backups cost a dollar or two per month; skipping them is the one genuinely reckless way to run this stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;heroku ps&lt;/code&gt; and the metrics dashboard are replaced by built-in server monitoring: CPU, memory, and disk tracking with configurable alerts, plus visibility into your Supervisor-managed workers. You'll actually gain observability here, because you can see the whole machine rather than a per-dyno abstraction, and read full logs on disk instead of Heroku's 1,500-line router buffer. Pair it with the external error tracker you already carried over, and your coverage exceeds what you had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I have to switch from PostgreSQL to MySQL?
&lt;/h3&gt;

&lt;p&gt;No. This is the question we hear most from Heroku teams, and the answer is a flat no. Deploynix provisions PostgreSQL as a first-class engine alongside MySQL and MariaDB. Your &lt;code&gt;pg_dump&lt;/code&gt; restores directly with &lt;code&gt;pg_restore&lt;/code&gt;, your migrations are untouched, and any Postgres-specific features you use (JSONB queries, full-text search) keep working.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much downtime should I expect?
&lt;/h3&gt;

&lt;p&gt;The write-downtime equals your final dump-and-restore window, typically 5-15 minutes for databases up to a few gigabytes, and the rest of the migration happens while Heroku serves production normally. With a 300-second DNS TTL set in advance, traffic follows the cutover within minutes. Larger databases can shrink the window further with logical replication, but most apps don't need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to my Heroku Scheduler jobs?
&lt;/h3&gt;

&lt;p&gt;They move to the Laravel scheduler, and they get better. Heroku Scheduler's minimum granularity is 10 minutes and its jobs live outside version control. On your server, a single cron entry runs &lt;code&gt;schedule:run&lt;/code&gt; every minute, so every frequency Laravel supports works natively and your schedule definitions live in &lt;code&gt;routes/console.php&lt;/code&gt; where they belong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I still roll back a bad deploy like &lt;code&gt;heroku releases:rollback&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Yes, and it's faster. Every zero-downtime deploy keeps previous releases on disk, and rollback is a one-click symlink switch back to the prior release, taking effect in seconds. Failed builds never go live in the first place, because the symlink only moves after a successful build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this migration harder than moving from Forge?
&lt;/h3&gt;

&lt;p&gt;Slightly, because you're changing hosting models rather than just panels, but the Laravel-side work is nearly identical. If you've read our &lt;a href="https://deploynix.io/blog/migrating-from-laravel-forge-to-deploynix-a-step-by-step-guide" rel="noopener noreferrer"&gt;Forge migration guide&lt;/a&gt;, the site, worker, and scheduler steps will look familiar; the Heroku-specific additions are the Procfile translation and the Postgres dump-and-restore, both covered above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Next Step
&lt;/h2&gt;

&lt;p&gt;Heroku was the right choice when your priority was shipping fast with zero operations, and there's no shame in having paid the premium while it bought you focus. But when the invoice crosses the line from convenience fee to burden, the math is hard to argue with: the same Laravel workload, on hardware four times larger, for 79-90% less, with PostgreSQL intact and your deploy workflow preserved.&lt;/p&gt;

&lt;p&gt;The whole migration is an afternoon of setup plus a week of parallel running, and nothing is irreversible until you scale the dynos to zero. The single next step: provision a server with Deploynix on the provider of your choice, restore last night's &lt;code&gt;pg_dump&lt;/code&gt; to it, and point a test subdomain at the result. Once you've watched your app run for a week on a $12 server, the invoice decision makes itself.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>migration</category>
      <category>heroku</category>
      <category>costoptimization</category>
    </item>
    <item>
      <title>Passkey Authentication in Laravel 13: Setup, HTTPS Requirements, and a Sane Rollout Plan</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Wed, 19 Aug 2026 11:13:05 +0000</pubDate>
      <link>https://dev.to/deploynix/passkey-authentication-in-laravel-13-setup-https-requirements-and-a-sane-rollout-plan-534g</link>
      <guid>https://dev.to/deploynix/passkey-authentication-in-laravel-13-setup-https-requirements-and-a-sane-rollout-plan-534g</guid>
      <description>&lt;p&gt;Right now, somewhere, a botnet is replaying leaked email and password pairs against a Laravel login form. Not because anyone targeted that app specifically, but because credential stuffing is cheap, automated, and it works. Verizon's Data Breach Investigations Report has said the same thing for years: stolen credentials remain one of the most common ways attackers get in, showing up in roughly a third of breaches (&lt;a href="https://www.verizon.com/business/resources/reports/dbir/" rel="noopener noreferrer"&gt;Verizon DBIR&lt;/a&gt;). Your users reuse passwords. Some of those passwords are already in a dump somewhere. That's the baseline reality every login form inherits.&lt;/p&gt;

&lt;p&gt;Rate limiting helps. TOTP helps more. But both are mitigations layered on top of the same structural flaw: a shared secret that your server stores, your user remembers, and any phishing page can ask for. Passkeys remove the shared secret entirely, which is why they're the first authentication upgrade in a long time that changes the math instead of just raising the cost.&lt;/p&gt;

&lt;p&gt;Laravel 13, released March 17, 2026, ships first-party passkey authentication support, so the integration excuse is gone (&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;). What's left is the operational half: relying party ID decisions you can't undo, the HTTPS requirement that follows you into staging, testing without a fingerprint reader on your CI runner, and an account recovery story that's honestly harder than the code. We run Laravel infrastructure for a living, so this post covers both halves. The code first, then the parts that actually determine whether your rollout goes well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Are Passkeys Worth Adopting Now?
&lt;/h2&gt;

&lt;p&gt;Passkeys are phishing-resistant, immune to credential stuffing, and now supported natively in Laravel 13. The FIDO Alliance reported that over 15 billion online accounts could use passkeys by 2025 (&lt;a href="https://fidoalliance.org/passkeys/" rel="noopener noreferrer"&gt;FIDO Alliance&lt;/a&gt;), and every major platform ships an authenticator. The ecosystem argument that justified waiting no longer holds.&lt;/p&gt;

&lt;p&gt;The security properties come from the architecture, not from policy. A passkey is a public-private key pair. Your server stores only the public key. The private key never leaves the user's authenticator, whether that's Face ID on an iPhone, Windows Hello on a laptop, or a hardware key like a YubiKey. When your database gets dumped, the attacker gets a pile of public keys that are useless for logging in anywhere, including your own app.&lt;/p&gt;

&lt;p&gt;Phishing resistance is the part that surprises people. A passkey is cryptographically bound to the domain it was registered on. If a user lands on &lt;code&gt;deplyonix-login.example&lt;/code&gt; instead of your real domain, their browser simply won't offer the passkey. There's no "user was tired and typed their password into the wrong site" failure mode, because there's nothing to type. This isn't vendor marketing; it's how the W3C WebAuthn specification defines credential scoping (&lt;a href="https://www.w3.org/TR/webauthn-2/" rel="noopener noreferrer"&gt;W3C WebAuthn Level 2&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;And credential stuffing? There's nothing to stuff. No secret exists in more than one place, so a breach at some unrelated site can't be replayed against yours. For anyone who has watched fail2ban logs scroll past at 3 a.m., that alone justifies the project.&lt;/p&gt;

&lt;p&gt;The remaining question was always developer effort. Before Laravel 13, you were wiring up a third-party WebAuthn package, hand-rolling the ceremony endpoints, and hoping you got attestation validation right. Now the framework handles the ceremonies, and if you're planning the framework upgrade anyway, 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; covers that path. In our experience the upgrade and the passkey rollout are best treated as two separate deploys, for reasons we'll get to in the rollout section.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does WebAuthn Actually Work?
&lt;/h2&gt;

&lt;p&gt;Strip away the acronyms and WebAuthn is a challenge-response protocol built on public-key cryptography, standardized by the W3C (&lt;a href="https://www.w3.org/TR/webauthn-2/" rel="noopener noreferrer"&gt;W3C WebAuthn Level 2&lt;/a&gt;). The server issues a random challenge. The authenticator signs it with a private key it never reveals. The server verifies the signature with the public key it stored at registration. That's the whole trick. Everything else is packaging.&lt;/p&gt;

&lt;p&gt;Two ceremonies matter: registration (creating a credential) and authentication (using it).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Registration Ceremony
&lt;/h3&gt;

&lt;p&gt;When a user adds a passkey, your server generates a challenge plus some metadata: your app's name, the relying party ID (more on that shortly), and the user's identifier. The browser hands this to the platform authenticator, which asks the user to confirm with a biometric or PIN. The authenticator then mints a brand-new key pair scoped to your domain, signs the challenge, and returns the public key and a credential ID. Your server verifies the response and stores both.&lt;/p&gt;

&lt;p&gt;One detail worth internalizing: a new key pair is created per site, per user. There's no master key that opens everything. The passkey for your app is mathematically unrelated to the passkey for the user's bank.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Authentication Ceremony
&lt;/h3&gt;

&lt;p&gt;Login runs the same dance in reverse. The server sends a fresh challenge. The browser finds credentials matching your relying party ID, the user confirms with Face ID, Windows Hello, or a hardware key tap, and the authenticator signs the challenge with the stored private key. Your server looks up the public key by credential ID, verifies the signature, checks the challenge matches the one it issued, and logs the user in.&lt;/p&gt;

&lt;p&gt;Because the challenge is random and single-use, replay attacks fail. Because the signature is bound to the origin, phishing pages fail. Because verification uses only a public key, database leaks fail. It's a short list of failure modes, and that's the point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Do Synced Passkeys Fit In?
&lt;/h3&gt;

&lt;p&gt;Platform passkeys sync through the vendor's credential manager: iCloud Keychain on Apple devices, Google Password Manager on Android and Chrome. That's how a passkey created on a phone works on the same user's laptop. Syncing is what made passkeys consumer-viable, and it's also the piece that makes some security teams pause, because the platform account becomes part of your trust chain. We'll come back to that trade-off honestly in the considerations section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passwords vs TOTP vs Passkeys: How Do They Compare?
&lt;/h2&gt;

&lt;p&gt;No comparison table settles an architecture decision by itself, but this one clarifies what each factor actually defends against.&lt;/p&gt;

&lt;p&gt;Property&lt;/p&gt;

&lt;p&gt;Passwords&lt;/p&gt;

&lt;p&gt;TOTP (authenticator apps)&lt;/p&gt;

&lt;p&gt;Passkeys&lt;/p&gt;

&lt;p&gt;Secret stored on server&lt;/p&gt;

&lt;p&gt;Yes (hashed)&lt;/p&gt;

&lt;p&gt;Yes (shared seed)&lt;/p&gt;

&lt;p&gt;No (public key only)&lt;/p&gt;

&lt;p&gt;Phishing resistant&lt;/p&gt;

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

&lt;p&gt;No (codes can be relayed in real time)&lt;/p&gt;

&lt;p&gt;Yes (bound to domain)&lt;/p&gt;

&lt;p&gt;Credential stuffing resistant&lt;/p&gt;

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

&lt;p&gt;Partially (second factor required)&lt;/p&gt;

&lt;p&gt;Yes (nothing to stuff)&lt;/p&gt;

&lt;p&gt;Works offline on user side&lt;/p&gt;

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

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

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

&lt;p&gt;Login friction&lt;/p&gt;

&lt;p&gt;Medium (typing, resets)&lt;/p&gt;

&lt;p&gt;High (open app, copy code)&lt;/p&gt;

&lt;p&gt;Low (one biometric prompt)&lt;/p&gt;

&lt;p&gt;Recovery difficulty&lt;/p&gt;

&lt;p&gt;Low (email reset)&lt;/p&gt;

&lt;p&gt;Medium (backup codes)&lt;/p&gt;

&lt;p&gt;High (needs deliberate design)&lt;/p&gt;

&lt;p&gt;Server-side breach impact&lt;/p&gt;

&lt;p&gt;High if hashes crack&lt;/p&gt;

&lt;p&gt;High (seeds are symmetric)&lt;/p&gt;

&lt;p&gt;Minimal&lt;/p&gt;

&lt;h3&gt;
  
  
  Passwords
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Universal. Every user, browser, device, and support workflow understands them. Recovery via email reset is a solved problem, and no platform dependency exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; The fallback layer during a passkey transition, and users on locked-down or shared devices where authenticators aren't available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Reuse and phishing are unfixable at the protocol level. Everything you bolt on (complexity rules, breach-list checks, rate limiting) is compensation for the core design.&lt;/p&gt;

&lt;h3&gt;
  
  
  TOTP
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Big security lift over passwords alone, well understood, works without connectivity, cheap to implement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; A second factor where passkeys aren't viable yet, and compliance regimes that explicitly name OTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; The seed is a shared secret sitting in your database, and modern phishing kits proxy TOTP codes in real time. Adversary-in-the-middle toolkits treat TOTP as a speed bump, not a wall.&lt;/p&gt;

&lt;h3&gt;
  
  
  Passkeys
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Phishing-resistant by construction, immune to stuffing and database-dump replay, and genuinely faster to use than a password plus a code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Primary authentication for consumer and SaaS apps on a stable production domain, and any team tired of password-reset support tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Recovery must be designed, not assumed. The relying party ID welds credentials to your domain. Enterprise users on managed devices may not be able to enroll at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Set Up Passkeys in Laravel 13?
&lt;/h2&gt;

&lt;p&gt;The walkthrough below assumes Laravel 13 on PHP 8.5, which is the stack Deploynix provisions by default on Ubuntu 24.04. The first-party support gives you the ceremony plumbing; you own the routes, the UI, and the policy decisions.&lt;/p&gt;

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

&lt;p&gt;The relying party settings live in config. Set them deliberately, because the &lt;code&gt;id&lt;/code&gt; value is the one you can't casually change later:&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;// config/passkeys.php&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;'relying_party'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// Must be your production registrable domain, or a suffix of it.&lt;/span&gt;
        &lt;span class="s1"&gt;'id'&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;'PASSKEYS_RP_ID'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.url'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;PHP_URL_HOST&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;

    &lt;span class="c1"&gt;// How long the browser waits for the user to complete the ceremony.&lt;/span&gt;
    &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// Require user verification (biometric or PIN), not just presence.&lt;/span&gt;
    &lt;span class="s1"&gt;'user_verification'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required'&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;Keep &lt;code&gt;PASSKEYS_RP_ID&lt;/code&gt; in your environment configuration per environment, so staging registers credentials against the staging domain and production against the real one. Never read &lt;code&gt;env()&lt;/code&gt; outside config files; use &lt;code&gt;config('passkeys.relying_party.id')&lt;/code&gt; everywhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storing Credentials
&lt;/h3&gt;

&lt;p&gt;A user has many passkeys, and you want them to. One per device family is the healthy pattern:&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;// database/migrations/2026_08_02_000000_create_passkeys_table.php&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;up&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="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passkeys'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&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;constrained&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;cascadeOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "MacBook Touch ID", "YubiKey 5C"&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'credential_id'&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;unique&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public_key'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsignedBigInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sign_count'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'transports'&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;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last_used_at'&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;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&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 model is deliberately boring:&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;// app/Models/Passkey.php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Models&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\Factories\HasFactory&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\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;Illuminate\Database\Eloquent\Relations\BelongsTo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Passkey&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;$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;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'credential_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sign_count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'transports'&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;'transports'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'last_used_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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsTo&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Registration Endpoints
&lt;/h3&gt;

&lt;p&gt;Registration is a two-step round trip: fetch options, then verify the browser's response.&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;// app/Http/Controllers/PasskeyController.php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Http\Controllers&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;App\Http\Requests\StorePasskeyRequest&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;App\Models\Passkey&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\Http\JsonResponse&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\Http\Request&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\Support\Facades\Passkeys&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PasskeyController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&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;registrationOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Passkeys&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;registrationOptions&lt;/span&gt;&lt;span class="p"&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;user&lt;/span&gt;&lt;span class="p"&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;session&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;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passkey_registration_challenge'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$options&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;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;StorePasskeyRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Passkeys&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;verifyRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;user&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;user&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;credential&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;validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'credential'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;challenge&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;session&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;pull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passkey_registration_challenge'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&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;user&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;passkeys&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;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'credential_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$verified&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credentialId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'public_key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$verified&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'transports'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$verified&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'registered'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&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;On the front end, the browser API does the heavy lifting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resources/js/passkeys.js&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;registerPasskey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/passkeys/register/options&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-CSRF-TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;csrfToken&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseCreationOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/passkeys/register&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-CSRF-TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;csrfToken&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credential&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;h3&gt;
  
  
  The Authentication Endpoints
&lt;/h3&gt;

&lt;p&gt;Login mirrors registration: issue a challenge, verify the assertion, then authenticate the session.&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;authenticationOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Passkeys&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;authenticationOptions&lt;/span&gt;&lt;span class="p"&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;session&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;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passkey_auth_challenge'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$options&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;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$passkey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Passkeys&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;verifyAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;credential&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;'credential'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;challenge&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;session&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;pull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passkey_auth_challenge'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$passkey&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forceFill&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'last_used_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&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;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;auth&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;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$passkey&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remember&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&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;session&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;regenerate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'authenticated'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&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;One front-end detail worth shipping on day one: conditional UI. Adding &lt;code&gt;autocomplete="username webauthn"&lt;/code&gt; to your email input lets the browser surface saved passkeys in the same autofill dropdown users already trust. Adoption rates are meaningfully better when the passkey appears where the password used to, instead of behind a separate button nobody clicks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Device Management UI
&lt;/h3&gt;

&lt;p&gt;Every passkey deployment needs a management screen, and it's where product decisions hide. Ours went through three revisions. The essentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;List each passkey with its user-chosen name, creation date, and &lt;code&gt;last_used_at&lt;/code&gt;. Users forget which device is which; the timestamp is what makes deletion feel safe.&lt;/li&gt;
&lt;li&gt;Allow renaming. "iCloud Keychain" means nothing to someone with three Apple devices.&lt;/li&gt;
&lt;li&gt;Guard deletion. If a user has gone passwordless, refuse to delete their final passkey until another one exists or a password is set. This one check prevents a whole category of lockout tickets.&lt;/li&gt;
&lt;li&gt;Require a fresh authentication (password confirm or recent passkey assertion) before adding or removing credentials. Session hijacking shouldn't be enough to plant an attacker's passkey.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Is the Relying Party ID a One-Way Door?
&lt;/h2&gt;

&lt;p&gt;The relying party ID is the domain your credentials are welded to, and it's the single most consequential configuration value in this entire feature. Every passkey your users register is scoped to it, permanently. Get it wrong and there is no migration script, because the private keys live on devices you don't control.&lt;/p&gt;

&lt;p&gt;The scoping rules come straight from the spec (&lt;a href="https://www.w3.org/TR/webauthn-2/" rel="noopener noreferrer"&gt;W3C WebAuthn Level 2&lt;/a&gt;). The RP ID must be the origin's registrable domain or a suffix of it. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RP ID &lt;code&gt;example.com&lt;/code&gt; works for &lt;code&gt;example.com&lt;/code&gt;, &lt;code&gt;app.example.com&lt;/code&gt;, and &lt;code&gt;staging.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;RP ID &lt;code&gt;app.example.com&lt;/code&gt; works only on &lt;code&gt;app.example.com&lt;/code&gt;. Credentials registered there are invisible to &lt;code&gt;example.com&lt;/code&gt; and to any sibling subdomain.&lt;/li&gt;
&lt;li&gt;RP ID &lt;code&gt;example.com&lt;/code&gt; will never work from &lt;code&gt;example.net&lt;/code&gt;, no matter what you do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our advice: set the RP ID to your apex domain unless you have a concrete reason not to, such as genuinely independent products on separate subdomains. The apex gives you room to move the app between subdomains later without stranding credentials.&lt;/p&gt;

&lt;p&gt;The scenario that actually burns people is the rebrand. If you launch passkeys on &lt;code&gt;oldname.com&lt;/code&gt; and later move to &lt;code&gt;newname.com&lt;/code&gt;, every passkey dies at the moment of cutover. Not "needs re-sync". Dead. Your migration plan becomes "keep the old domain serving auth indefinitely" or "force every user through re-enrollment", and both are miserable. If a domain change is even a remote possibility, delay the passkey launch until the domain is settled.&lt;/p&gt;

&lt;p&gt;There's a quieter version of the same trap that we see on our own platform. Deploynix gives every new site a vanity subdomain (&lt;code&gt;yourapp.deploynix.cloud&lt;/code&gt;) so you can deploy before DNS is sorted. That's great for smoke-testing, but if you enable passkey registration while users are hitting the vanity domain and then move to your real custom domain, those credentials are scoped to &lt;code&gt;deploynix.cloud&lt;/code&gt; infrastructure and won't follow you. Treat the vanity domain as pre-production for anything WebAuthn-related, and only open passkey enrollment once traffic is on the final domain.&lt;/p&gt;

&lt;p&gt;One more nuance: your staging environment on &lt;code&gt;staging.example.com&lt;/code&gt; with RP ID &lt;code&gt;example.com&lt;/code&gt; will happily accept production passkeys, because the RP ID matches. If you want staging credentials isolated (you usually do), set staging's RP ID to the full staging hostname in that environment's config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Do You Need Real HTTPS Everywhere, Including Staging?
&lt;/h2&gt;

&lt;p&gt;WebAuthn only exists in secure contexts. Browsers do not expose &lt;code&gt;navigator.credentials&lt;/code&gt; over plain HTTP; the API is simply absent, per the spec's secure-context requirement (&lt;a href="https://www.w3.org/TR/webauthn-2/" rel="noopener noreferrer"&gt;W3C WebAuthn Level 2&lt;/a&gt;). The single exception is &lt;code&gt;localhost&lt;/code&gt;, which browsers treat as secure for development. There is no flag, header, or workaround for a real hostname served over HTTP.&lt;/p&gt;

&lt;p&gt;This has a consequence teams keep rediscovering the hard way: you cannot credibly test passkeys on an HTTP staging server. Self-signed certificates are nearly as bad. Some browsers refuse WebAuthn entirely behind certificate warnings, others behave inconsistently, and either way your QA pass is exercising code paths your users will never hit. If staging doesn't have a real certificate on a real domain, your passkey testing is fiction.&lt;/p&gt;

&lt;p&gt;So the infrastructure prerequisite list is short but strict: valid TLS on production, valid TLS on staging, and valid TLS on any preview environment where someone might click "Add a passkey". If your certificate story is shaky anywhere in that chain, fix it before writing a line of ceremony code. Our &lt;a href="https://deploynix.io/blog/a-laravel-developers-production-security-checklist-2026-edition" rel="noopener noreferrer"&gt;production security checklist&lt;/a&gt; puts HTTPS at the top for exactly this class of reason: modern browser APIs increasingly refuse to function without it.&lt;/p&gt;

&lt;h3&gt;
  
  
  How This Works on Deploynix
&lt;/h3&gt;

&lt;p&gt;We built our SSL handling around the assumption that every environment deserves a real certificate, and passkeys are the feature that turns that from nice-to-have into hard requirement.&lt;/p&gt;

&lt;p&gt;Every site you add gets a free Let's Encrypt certificate provisioned and auto-renewed, staging subdomains included. Because staging runs on a real subdomain with a real cert, the WebAuthn ceremonies behave exactly as they will in production, including the RP ID scoping behavior described above. Wildcard certificates cover multi-subdomain setups, and the details (issuance, renewal, wildcard DNS validation) are in our &lt;a href="https://deploynix.io/blog/the-complete-guide-to-ssl-certificates-on-deploynix-lets-encrypt-wildcards-vanity-domains" rel="noopener noreferrer"&gt;complete guide to SSL certificates on Deploynix&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Preview environments matter more than you'd expect here. Auth changes are exactly the kind of PR you want reviewed on a live URL rather than in screenshots, and because &lt;a href="https://deploynix.io/blog/how-to-set-up-preview-environments-for-pull-requests-with-deploynix" rel="noopener noreferrer"&gt;preview environments for pull requests&lt;/a&gt; come up with valid HTTPS, a reviewer can register a real passkey against the preview build from their own laptop before the merge.&lt;/p&gt;

&lt;p&gt;Two more pieces of the platform earn their keep during a rollout. Zero-downtime deploys with one-click rollback mean an auth regression is a rollback away instead of an incident, which lowers the stakes of shipping the enrollment prompts iteratively. And server monitoring with alerts gives you a place to watch error rates on the ceremony endpoints as each rollout phase widens. None of this is passkey-specific machinery; it's the same baseline we'd recommend for any change to your login path, alongside the hardening in &lt;a href="https://deploynix.io/blog/securing-your-laravel-deployment-ssh-keys-firewall-rules-encrypted-credentials" rel="noopener noreferrer"&gt;securing your Laravel deployment&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Test WebAuthn Without a Fingerprint Reader?
&lt;/h2&gt;

&lt;p&gt;You don't need a drawer of YubiKeys or a CI runner with Face ID. Browsers ship virtual authenticators precisely for this.&lt;/p&gt;

&lt;p&gt;For manual testing, Chrome DevTools has a WebAuthn panel (More tools, then WebAuthn) where you can add a virtual authenticator, choose its transport and whether it supports resident keys and user verification, then run your registration and login flows against it. Registered credentials appear in the panel, which makes it easy to verify what your server actually stored.&lt;/p&gt;

&lt;p&gt;For automated coverage, Playwright can drive the same machinery over the Chrome DevTools Protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/e2e/passkey-login.spec.js&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;context&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;newCDPSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WebAuthn.enable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;authenticatorId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WebAuthn.addVirtualAuthenticator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ctap2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;internal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;hasResidentKey&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="na"&gt;hasUserVerification&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="na"&gt;isUserVerified&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="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;With that in place, &lt;code&gt;navigator.credentials.create()&lt;/code&gt; and &lt;code&gt;.get()&lt;/code&gt; resolve automatically in the test run, no human finger required. We run a small suite like this against preview environments before auth-related merges.&lt;/p&gt;

&lt;p&gt;On the PHP side, keep your Pest feature tests focused on what the server owns: challenge issuance and expiry, session challenge consumption (a challenge must not verify twice), the deletion guard on a user's last credential, and the re-authentication requirement on the management endpoints. Use model factories for &lt;code&gt;Passkey&lt;/code&gt; records and treat the cryptographic verification itself as a boundary you exercise through the browser-level tests. Trying to hand-forge attestation objects in PHPUnit is effort better spent elsewhere.&lt;/p&gt;

&lt;p&gt;One habit worth adopting: test at least once on a real device per platform family before each rollout phase. Virtual authenticators validate your protocol flow, but they won't catch a confusing iCloud Keychain prompt or a Windows Hello dialog that appears behind the browser window. The last-mile UX lives outside your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does a Sane Rollout Plan Look Like?
&lt;/h2&gt;

&lt;p&gt;The technology is the easy half. The rollout is where passkey projects succeed or quietly stall. The plan that has worked, for us and for most teams we've talked to, is three phases with explicit gates between them. The unifying rule: passwords remain a working fallback until the data says otherwise, and "day one" is never the day you remove them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Opt-In for People Who Ask
&lt;/h3&gt;

&lt;p&gt;Ship passkey registration in account settings behind a feature flag, announce it in a changelog, and say nothing else. Your early adopters (the users who already use password managers and hardware keys) will find it, exercise your edge cases, and file better bug reports than QA ever will.&lt;/p&gt;

&lt;p&gt;What to watch during this phase: ceremony completion rate (started registrations vs. stored credentials), errors by browser and platform, and whether anyone hits the last-credential deletion guard. A completion rate below roughly 90 percent usually means a UX problem, not a protocol one; hunt for it before widening the audience.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: Prompt After Successful Login
&lt;/h3&gt;

&lt;p&gt;Once the flow is boring, start prompting: immediately after a successful password login, offer "Add a passkey to skip your password next time". This moment converts far better than a settings page ever will, because the user has just experienced the friction you're removing and has already proven their identity, so enrollment is safe.&lt;/p&gt;

&lt;p&gt;Cap the prompt. Ask at most once every few weeks per user, and honor a "don't ask again" choice permanently. An authentication feature that nags becomes an authentication feature users resent.&lt;/p&gt;

&lt;p&gt;Expect the support load to shift rather than spike. Password-reset tickets start declining; in their place come "what is a passkey" questions and a steady trickle of "it's asking for my fingerprint and I don't want that" concerns. Write the help-center article before Phase 2, not after. Two paragraphs explaining that the biometric never leaves the device prevents most of these tickets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: Default for New Signups
&lt;/h3&gt;

&lt;p&gt;When your existing-user metrics are healthy, make passkey creation the default path on registration, with password creation demoted to a secondary link. New users have no habit to unlearn, so this is where adoption compounds.&lt;/p&gt;

&lt;p&gt;Even here, keep the password path alive. Locked-down corporate devices, shared machines, and unsupported browsers all still exist. The end state for most apps isn't "no passwords"; it's "passwords are the rarely used fallback, monitored accordingly". Going fully passwordless is a Phase 4 that most teams, honestly, never need to reach, and it should wait until password logins are a rounding error in your metrics.&lt;/p&gt;

&lt;p&gt;Throughout all three phases, watch your login funnel the way you'd watch a checkout funnel: passkey login success rate, fallback-to-password rate, and time-to-authenticate. A rising fallback rate on one platform is your early warning that an OS update changed a prompt somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Should You Handle Account Recovery?
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable truth: account recovery is the actual hard problem, and passkeys make it harder before they make it easier. With passwords, "I forgot" resolves through an email reset. With a lost device holding an unsynced passkey, there's nothing to reset. The private key is gone.&lt;/p&gt;

&lt;p&gt;Synced passkeys soften this considerably. A user whose iPhone falls in a lake restores iCloud Keychain on the replacement phone and their passkeys come back. But hardware keys don't sync, some users disable sync, and platform account recovery becomes your problem by proxy. Design for the worst case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Encourage a second passkey at enrollment. After a user registers their first credential, prompt for one more on a different device. Two independent authenticators eliminate most lockout scenarios outright.&lt;/li&gt;
&lt;li&gt;Issue recovery codes, exactly as you would for TOTP. One-time-use, hashed at rest, shown once. Boring and proven.&lt;/li&gt;
&lt;li&gt;Decide what email reset means now. If password reset via email still exists, your account security equals your users' mailbox security, same as before passkeys. That's an acceptable, honest trade-off during transition; just make the decision explicitly rather than by default, and rate-limit and alert on the reset path.&lt;/li&gt;
&lt;li&gt;Write the support playbook before launch. When someone fails all automated recovery, what does your team verify before restoring access? Recovery-by-support-ticket is the channel attackers target once the front door hardens; social engineering against help desks is a well-worn playbook. Define the identity checks, log every manual recovery, and review them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you choose, the recovery path is now your weakest link, because attackers route around strength. It deserves the same review rigor as the login path itself, and it belongs on the agenda the next time you run a &lt;a href="https://deploynix.io/blog/hardening-your-deploynix-server-a-step-by-step-security-audit" rel="noopener noreferrer"&gt;step-by-step security audit of your server&lt;/a&gt; and application.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are the Honest Downsides?
&lt;/h2&gt;

&lt;p&gt;We're bullish on passkeys, but a few realities deserve plain statement before you commit a quarter to this.&lt;/p&gt;

&lt;p&gt;Enterprise-managed devices are the biggest friction. Plenty of corporate laptops run with platform authenticators restricted, browser sync disabled by policy, or hardware keys mandated and everything else blocked. If you sell B2B, assume a meaningful slice of your users cannot enroll a synced passkey, and keep password-plus-TOTP as a fully supported path for them indefinitely. Ask your largest customers' IT teams before Phase 3, not after.&lt;/p&gt;

&lt;p&gt;Cross-device sync is a trust trade-off, not a free lunch. A synced passkey is as secure as the Apple or Google account it syncs through. For most consumers that account is far better protected than their password habits, so it's a clear net win. But some security teams reasonably dislike delegating part of the trust chain to a platform vendor, and for high-security contexts, device-bound credentials on hardware keys remain the stricter answer. Know which posture your product needs.&lt;/p&gt;

&lt;p&gt;There's also the long tail: shared family tablets, kiosk machines, users on old browsers, and people who are simply uncomfortable with a fingerprint prompt regardless of where the biometric is processed. In 2026 browser support is excellent, but "excellent" isn't "universal", and your login form has to serve everyone. That's the real reason every rollout phase above keeps a fallback: not caution for its own sake, but because the edge cases are people.&lt;/p&gt;

&lt;p&gt;None of these are reasons to skip passkeys. They're reasons the rollout is phased, the password path survives, and the metrics get watched.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do passkeys replace two-factor authentication?
&lt;/h3&gt;

&lt;p&gt;Functionally, a passkey with user verification is already two factors in one gesture: possession of the device plus the biometric or PIN that opens it. Most teams treat passkey login as satisfying their MFA requirement. If a compliance framework you're subject to names OTP specifically, check with your auditor before dropping TOTP.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a user loses their phone?
&lt;/h3&gt;

&lt;p&gt;If the passkey was synced through iCloud Keychain or Google Password Manager, it restores with the platform account on the new device. If it was device-bound, that credential is gone, which is why you should encourage a second passkey at enrollment and issue recovery codes. Recovery design, not the protocol, determines how painful this day is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I test passkeys in local development without HTTPS?
&lt;/h3&gt;

&lt;p&gt;Yes, on &lt;code&gt;localhost&lt;/code&gt; only, which browsers treat as a secure context. Any other hostname, including &lt;code&gt;.test&lt;/code&gt; domains and LAN IPs, requires valid HTTPS or the WebAuthn API won't exist on the page. For staging and preview environments, use real certificates on real domains.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will passkeys registered on staging work in production?
&lt;/h3&gt;

&lt;p&gt;Only if the relying party IDs align, and usually you don't want them to. Set staging's RP ID to the full staging hostname so its credentials stay isolated, and set production's RP ID to your apex domain. And remember the reverse trap: if production's RP ID is the apex, production passkeys will work on staging subdomains too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should we remove passwords once passkey adoption is high?
&lt;/h3&gt;

&lt;p&gt;Not on any schedule you set in advance. Let the metrics decide: when password logins are a fraction of a percent and your recovery flow has survived real-world contact, going passwordless becomes a small step instead of a leap. Most apps run hybrid indefinitely, and that's a fine end state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start This Week
&lt;/h2&gt;

&lt;p&gt;Passkeys are the rare security upgrade that improves the user experience while removing an entire attack class. The Laravel 13 support takes care of the cryptography; your job is the operational judgment around it. Pick your relying party ID like it's permanent, because it is. Give staging a real certificate so your testing means something. Roll out in phases with passwords intact. And spend your design effort on recovery, because that's where the hard failures live.&lt;/p&gt;

&lt;p&gt;The concrete next step: get a staging environment on a real subdomain with valid HTTPS, wire up the registration ceremony behind a feature flag, and run the flow end to end with a Chrome virtual authenticator and then with your own thumb. Once that loop feels boring, you're ready for Phase 1. On Deploynix, the staging cert is already handled the moment you add the site, so the infrastructure part of that list takes about as long as reading this sentence.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>security</category>
      <category>passkeys</category>
      <category>laravel13</category>
    </item>
    <item>
      <title>Laravel 13 Native Attributes: Slimmer Models and a Cleaner Codebase</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Mon, 17 Aug 2026 11:04:11 +0000</pubDate>
      <link>https://dev.to/deploynix/laravel-13-native-attributes-slimmer-models-and-a-cleaner-codebase-49im</link>
      <guid>https://dev.to/deploynix/laravel-13-native-attributes-slimmer-models-and-a-cleaner-codebase-49im</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>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>
  </channel>
</rss>
