<?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: devTalk</title>
    <description>The latest articles on DEV Community by devTalk (@devtalk94).</description>
    <link>https://dev.to/devtalk94</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%2F3228626%2F5495a7f0-c356-4253-865c-b83286ec1fe4.jpg</url>
      <title>DEV Community: devTalk</title>
      <link>https://dev.to/devtalk94</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devtalk94"/>
    <language>en</language>
    <item>
      <title>Laravel .env Configuration: env() vs config() Explained</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Sat, 14 Nov 2026 15:12:18 +0000</pubDate>
      <link>https://dev.to/devtalk94/laravel-env-configuration-env-vs-config-explained-427h</link>
      <guid>https://dev.to/devtalk94/laravel-env-configuration-env-vs-config-explained-427h</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 9 November 2026. See the&lt;/em&gt;&lt;em&gt;update log&lt;/em&gt;&lt;em&gt;at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The classic Laravel deployment bug goes like this: everything works on your machine, you run &lt;code&gt;php artisan config:cache&lt;/code&gt; in production, and a setting that was fine yesterday is suddenly &lt;code&gt;null&lt;/code&gt;. The cause is almost always an &lt;code&gt;env()&lt;/code&gt; call in the wrong place. This article explains how &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;env()&lt;/code&gt; and &lt;code&gt;config()&lt;/code&gt; fit together, with short examples you can run, and covers the parts that bite in practice: value types, quoting, environment files, config caching and encrypted env files.&lt;/p&gt;

&lt;p&gt;Everything below was run on Laravel 13.40.0 with PHP 8.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow in one line
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; (or a real environment variable) → read by &lt;code&gt;env()&lt;/code&gt; &lt;strong&gt;inside a file in&lt;/strong&gt;&lt;code&gt;config/&lt;/code&gt; → read everywhere else with &lt;code&gt;config()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Laravel docs state the rule directly: once the configuration is cached, the &lt;code&gt;.env&lt;/code&gt; file is no longer loaded, so you should only call &lt;code&gt;env()&lt;/code&gt; from your configuration files. Everything else in the app reads &lt;code&gt;config()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A working example: adding your own setting
&lt;/h2&gt;

&lt;p&gt;Say you integrate a payment API. First add the values to &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PAYMENT_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.pay.example.test
&lt;span class="nv"&gt;PAYMENT_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30
&lt;span class="nv"&gt;PAYMENT_DEBUG&lt;/span&gt;&lt;span class="o"&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;Then expose them through a config file. Here I added an entry to &lt;code&gt;config/services.php&lt;/code&gt;. The second argument to &lt;code&gt;env()&lt;/code&gt; is the default, used when the variable doesn't exist:&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/services.php&lt;/span&gt;
&lt;span class="s1"&gt;'payment'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'url'&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;'PAYMENT_API_URL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'https://sandbox.pay.example.test'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&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;'PAYMENT_TIMEOUT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'debug'&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;'PAYMENT_DEBUG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&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 application code, use &lt;code&gt;config()&lt;/code&gt; with dot notation. Using the same pattern in a controller, job or service:&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;$url&lt;/span&gt;     &lt;span class="o"&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;'services.payment.url'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&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;'services.payment.timeout'&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;To see the effect, I printed both calls from an Artisan command. With nothing in &lt;code&gt;.env&lt;/code&gt;, the defaults apply. After adding the three lines, the values come through, already converted: the timeout is an integer, and &lt;code&gt;true&lt;/code&gt; became a real boolean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- nothing in .env --
env(PAYMENT_API_URL)             = NULL
config(services.payment.url)     = 'https://sandbox.pay.example.test'
config(services.payment.timeout) = 10
config(services.payment.debug)   = false

-- after adding the three lines --
env(PAYMENT_API_URL)             = 'https://api.pay.example.test'
config(services.payment.url)     = 'https://api.pay.example.test'
config(services.payment.timeout) = 30
config(services.payment.debug)   = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;php artisan probe&lt;/code&gt; in these outputs is a throwaway command I defined in &lt;code&gt;routes/console.php&lt;/code&gt; to print the values; it is not built into Laravel.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Real environment variables beat .env
&lt;/h2&gt;

&lt;p&gt;A variable set in the process environment (by your shell, systemd, Docker, your host's dashboard) overrides the same key in &lt;code&gt;.env&lt;/code&gt;. This is what the docs describe, and it is why production servers often need no &lt;code&gt;.env&lt;/code&gt; file at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ PAYMENT_API_URL=https://override.example.test php artisan probe
env(PAYMENT_API_URL)         = 'https://override.example.test'
config(services.payment.url) = 'https://override.example.test'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Value types: what env() returns
&lt;/h2&gt;

&lt;p&gt;Everything in &lt;code&gt;.env&lt;/code&gt; is a string, except a few reserved words that &lt;code&gt;env()&lt;/code&gt; converts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;.env&lt;/code&gt; value&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;env()&lt;/code&gt; returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; / &lt;code&gt;(true)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; (bool)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;false&lt;/code&gt; / &lt;code&gt;(false)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;false&lt;/code&gt; (bool)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;empty&lt;/code&gt; / &lt;code&gt;(empty)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;''&lt;/code&gt; (empty string)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; / &lt;code&gt;(null)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I tested a few of these, including one the docs don't spell out, a quoted &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 plaintext"&gt;&lt;code&gt;.env:  FLAG_TRUE=true  FLAG_FALSE=false  FLAG_QUOTED_FALSE="false"
       FLAG_EMPTY=     FLAG_NULL=null    FLAG_TEXT=hello

FLAG_TRUE          true
FLAG_FALSE         false
FLAG_QUOTED_FALSE  false
FLAG_EMPTY         ''
FLAG_NULL          NULL
FLAG_TEXT          'hello'
FLAG_UNSET         NULL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the quoted &lt;code&gt;"false"&lt;/code&gt; still came back as boolean &lt;code&gt;false&lt;/code&gt;, so quotes do not keep the word as a string. Also note that a key with an empty value (&lt;code&gt;FLAG_EMPTY=&lt;/code&gt;) returns an empty string, which is not the same as &lt;code&gt;null&lt;/code&gt;, and a key that is missing entirely gives &lt;code&gt;null&lt;/code&gt; unless you pass a default. If a setting can legitimately be a plain string like &lt;code&gt;"false"&lt;/code&gt;, don't use those words as values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spaces need quotes
&lt;/h2&gt;

&lt;p&gt;A value containing spaces must be in double quotes. Without them the app doesn't just misread the value, it refuses to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .env
MY_TITLE=My Application

$ php artisan probe
The environment file is invalid!
Failed to parse dotenv file. Encountered unexpected whitespace at [My Application].
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing &lt;code&gt;MY_TITLE="My Application"&lt;/code&gt; fixes it. The docs show the same rule with &lt;code&gt;APP_NAME="My Application"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config:cache trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;php artisan config:cache&lt;/code&gt; merges all config files into one cached file, which is faster, and you should run it on deploy. But after that, Laravel stops loading &lt;code&gt;.env&lt;/code&gt; on each request, so a direct &lt;code&gt;env()&lt;/code&gt; call outside &lt;code&gt;config/&lt;/code&gt; only sees real system environment variables. Here is the demo. I cached the config, then changed the URL in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php artisan config:cache
   INFO  Configuration cached successfully.

$ sed -i 's#^PAYMENT_API_URL=.*#PAYMENT_API_URL=https://changed.example.test#' .env

$ php artisan probe
env(PAYMENT_API_URL)         = NULL
config(services.payment.url) = 'https://api.pay.example.test'

$ php artisan config:clear
   INFO  Configuration cache cleared successfully.

$ php artisan probe
env(PAYMENT_API_URL)         = 'https://changed.example.test'
config(services.payment.url) = 'https://changed.example.test'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lessons in one output. First, &lt;code&gt;env()&lt;/code&gt; in application code returned &lt;code&gt;NULL&lt;/code&gt; while the cache was active, even though the variable is in &lt;code&gt;.env&lt;/code&gt;. That's the production-only bug from the introduction. Second, &lt;code&gt;config()&lt;/code&gt; kept serving the old value, because the cache is a snapshot: changing &lt;code&gt;.env&lt;/code&gt; has no effect until you run &lt;code&gt;config:cache&lt;/code&gt; again (or &lt;code&gt;config:clear&lt;/code&gt; locally). So the deploy order is: update the environment, then rebuild the cache.&lt;/p&gt;

&lt;p&gt;The fix for the bug is always the same. Move the &lt;code&gt;env()&lt;/code&gt; call into a config file, as in the payment example, and read &lt;code&gt;config()&lt;/code&gt; in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment names and extra env files
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;APP_ENV&lt;/code&gt; names the current environment, and &lt;code&gt;App::environment()&lt;/code&gt; reads it:&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;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'local'&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'local'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'staging'&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;span class="cm"&gt;/* local OR staging */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs also describe an environment-specific file: if &lt;code&gt;APP_ENV&lt;/code&gt; is set externally or you pass &lt;code&gt;--env&lt;/code&gt;, Laravel looks for &lt;code&gt;.env.[name]&lt;/code&gt; and loads it instead of &lt;code&gt;.env&lt;/code&gt; if it exists. The word "instead" matters. I created a &lt;code&gt;.env.staging&lt;/code&gt; with only two keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env.staging:
APP_NAME="Staging App"
PAYMENT_API_URL=https://staging.pay.example.test

$ php artisan probe --env=staging
config(services.payment.url)     = 'https://staging.pay.example.test'
config(services.payment.timeout) = 10
config(services.payment.debug)   = false
config(app.name)                 = 'Staging App'
App::environment()               = 'staging'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeout and debug flag fell back to their defaults (10 and false) even though &lt;code&gt;.env&lt;/code&gt; sets them to 30 and true: the staging file replaced &lt;code&gt;.env&lt;/code&gt;, it was not layered on top. So a &lt;code&gt;.env.staging&lt;/code&gt; must contain &lt;em&gt;every&lt;/em&gt; variable the app needs. And when the named file does not exist, nothing breaks, you just get the default &lt;code&gt;.env&lt;/code&gt; under a different name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ APP_ENV=qa php artisan probe       # no .env.qa exists
config(services.payment.url) = 'https://api.pay.example.test'
config(app.name)             = 'Laravel'
App::environment()           = 'qa'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That quietly misleads: the app reports it is in &lt;code&gt;qa&lt;/code&gt; while running on the &lt;code&gt;.env&lt;/code&gt; values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching wrong types: the typed getters
&lt;/h2&gt;

&lt;p&gt;Laravel's &lt;code&gt;Config&lt;/code&gt; facade has typed getters that throw when a value is the wrong type, which turns a silent bug into a clear error at the call site:&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;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.payment.timeout'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 30&lt;/span&gt;
&lt;span class="nv"&gt;$url&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.payment.url'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.payment.url'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// throws&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Config::integer -&amp;gt; InvalidArgumentException: Configuration value for key [services.payment.url] must be an integer, string given.
Config::boolean -&amp;gt; InvalidArgumentException: Configuration value for key [services.payment.url] must be a boolean, string given.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keeping secrets out of git: encrypted env files
&lt;/h2&gt;

&lt;p&gt;The docs are clear that &lt;code&gt;.env&lt;/code&gt; should not be committed, since it holds credentials. If you want your environment in source control anyway, Laravel can encrypt it. The commands and the default cipher (AES-256-CBC, which needs a 32-character key) come from the docs. Here is the round trip I ran, with a throwaway key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php artisan env:encrypt --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
   INFO  Environment successfully encrypted.
  Key ........ 3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
  Cipher ..... AES-256-CBC
  Encrypted file ..... .env.encrypted

$ php artisan env:decrypt --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
   ERROR  Environment file already exists.

$ rm .env
$ php artisan env:decrypt --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
   INFO  Environment successfully decrypted.
  Decrypted file ..... .env

$ diff .env .env.original &amp;amp;&amp;amp; echo identical
identical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decrypting refuses to overwrite an existing &lt;code&gt;.env&lt;/code&gt; unless you pass &lt;code&gt;--force&lt;/code&gt;. On a server you can also set the key in the &lt;code&gt;LARAVEL_ENV_ENCRYPTION_KEY&lt;/code&gt; environment variable instead of passing &lt;code&gt;--key&lt;/code&gt;. With &lt;code&gt;--readable&lt;/code&gt;, variable names stay visible and only values are encrypted, which makes diffs in pull requests much more useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php artisan env:encrypt --readable --force --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF
$ cut -c1-40 .env.encrypted | head -3
APP_NAME=eyJpdiI6Ik1VQU5yU1BQa0pGK0VycVZ
APP_ENV=eyJpdiI6IjBpVDZTeFRwOVNxRlUxZGtU
APP_KEY=eyJpdiI6Im53RTkxbG9sS0J3SGtTR0tP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The ciphertext differs on every run; yours will look different.) Never commit the key, and don't reuse the key shown here. Store the real one in a password manager or your CI secret store. Per the docs, comments and blank lines from the original file are dropped in the readable form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;env()&lt;/code&gt;&lt;strong&gt;in controllers, models or services.&lt;/strong&gt; Works locally, returns &lt;code&gt;null&lt;/code&gt; after &lt;code&gt;config:cache&lt;/code&gt;. Read &lt;code&gt;config()&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to rebuild the cache on deploy.&lt;/strong&gt; The cache is a snapshot; new &lt;code&gt;.env&lt;/code&gt; values are ignored until you run &lt;code&gt;config:cache&lt;/code&gt; again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unquoted values with spaces.&lt;/strong&gt; The app refuses to boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming&lt;/strong&gt;&lt;code&gt;.env.staging&lt;/code&gt;&lt;strong&gt;adds to&lt;/strong&gt;&lt;code&gt;.env&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; It replaces it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting&lt;/strong&gt;&lt;code&gt;APP_ENV&lt;/code&gt;&lt;strong&gt;to a name with no matching file and assuming it is isolated.&lt;/strong&gt; It silently uses &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Committing&lt;/strong&gt;&lt;code&gt;.env&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; Commit &lt;code&gt;.env.example&lt;/code&gt; with placeholders, as the docs recommend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running&lt;/strong&gt;&lt;code&gt;config:cache&lt;/code&gt;&lt;strong&gt;on your dev machine.&lt;/strong&gt; You will chase "my change does nothing" for an hour. Run it on deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving&lt;/strong&gt;&lt;code&gt;APP_DEBUG=true&lt;/code&gt;&lt;strong&gt;in production.&lt;/strong&gt; See the checklist in the &lt;a href="https://dev-talk.com/secure-laravel-applications-before-production" rel="noopener noreferrer"&gt;production security article&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alternatives to a .env file
&lt;/h2&gt;

&lt;p&gt;On servers, real environment variables (systemd units, Docker's &lt;code&gt;environment&lt;/code&gt; or secrets, your platform's settings page, a secrets manager) avoid keeping a plaintext file on disk at all, and Laravel reads them with the same &lt;code&gt;env()&lt;/code&gt;-in-config pattern. That is a deployment preference rather than a Laravel requirement, so choose what fits your hosting. I only tested &lt;code&gt;.env&lt;/code&gt; and shell variables here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/common-api-design-mistakes" rel="noopener noreferrer"&gt;10 Common API Design Mistakes Developers Make&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I use env() anywhere if I never run config:cache?
&lt;/h3&gt;

&lt;p&gt;It will work, but you are one deploy script away from the bug. Reading &lt;code&gt;config()&lt;/code&gt; everywhere costs nothing and keeps you safe if caching is switched on later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my .env change not take effect?
&lt;/h3&gt;

&lt;p&gt;Either the config is cached (run &lt;code&gt;php artisan config:clear&lt;/code&gt;), or a real environment variable with the same name is overriding &lt;code&gt;.env&lt;/code&gt;. Check with &lt;code&gt;printenv NAME&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between .env and .env.example?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; holds your real values and stays out of git. &lt;code&gt;.env.example&lt;/code&gt; is committed and lists the variable names with placeholders so teammates know what to set.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the encrypted env file safe to commit?
&lt;/h3&gt;

&lt;p&gt;The docs say encrypted environment files may be placed in source control. The safety depends entirely on keeping the key out of the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://laravel.com/framework/docs/configuration#environment-configuration" rel="noopener noreferrer"&gt;Laravel documentation: Configuration, Environment Configuration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/laravel-env-configuration-env-vs-config" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>env</category>
      <category>configuration</category>
      <category>configcache</category>
    </item>
    <item>
      <title>Palo Alto PAN-OS CVE-2026-0330: Panorama Root RCE Fix</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Sat, 14 Nov 2026 07:40:57 +0000</pubDate>
      <link>https://dev.to/devtalk94/palo-alto-pan-os-cve-2026-0330-panorama-root-rce-fix-4603</link>
      <guid>https://dev.to/devtalk94/palo-alto-pan-os-cve-2026-0330-panorama-root-rce-fix-4603</guid>
      <description>&lt;p&gt;Palo Alto Networks published an advisory on 11 November 2026 for a PAN-OS flaw that lets an attacker run arbitrary code as root. The headline is alarming, and parts of it are justified: root on a security appliance is about as bad as it gets. But the details matter more than the headline. This one is &lt;strong&gt;CVE-2026-0330&lt;/strong&gt;, it affects &lt;strong&gt;Panorama&lt;/strong&gt;, it needs an authenticated administrator, and Palo Alto says it is not aware of any exploitation.&lt;/p&gt;

&lt;p&gt;A different PAN-OS flaw, CVE-2026-0322, was exploited in October and is easy to confuse with this one. Both are covered below so you can tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; CVE-2026-0330, a code injection flaw (CWE-94) that lets an authenticated administrator with network access run arbitrary code with root privileges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where:&lt;/strong&gt; Panorama running PAN-OS 12.2, 12.1, 11.2, 11.1 and 10.2. Cloud NGFW, Prisma Access and standalone PAN-OS firewalls are not affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Severity:&lt;/strong&gt; Medium, urgency "moderate" in the advisory, though the base score is high (see below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploited:&lt;/strong&gt; Palo Alto says it is not aware of malicious exploitation. Exploit maturity is listed as unreported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; upgrade Panorama to a fixed release. No workaround is known.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the advisory says
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail (from Palo Alto's advisory)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weakness&lt;/td&gt;
&lt;td&gt;CWE-94, code injection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requirement&lt;/td&gt;
&lt;td&gt;Authenticated administrator with network access. No user interaction. No special configuration needed to be affected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS 4.0&lt;/td&gt;
&lt;td&gt;CVSS-BT 6.1 (medium), CVSS-B 8.6. If management access is limited to a jump box, the scores drop to BT 5.7 and B 8.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exploitation&lt;/td&gt;
&lt;td&gt;"not aware of any malicious exploitation"; exploit maturity: unreported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workarounds&lt;/td&gt;
&lt;td&gt;None known&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Published&lt;/td&gt;
&lt;td&gt;11 November 2026; found internally by Palo Alto researcher Daniel Voce&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why 6.1 and 8.6 at the same time?
&lt;/h3&gt;

&lt;p&gt;One news write-up called those two numbers inconsistent. They aren't. The first, CVSS-BT, is the base score adjusted by threat information: here, exploit maturity "unreported" pulls it down. The second, CVSS-B, is the base score alone, which is high because a successful attack has high impact on confidentiality, integrity and availability. The advisory shows both, and its urgency rating follows the lower, threat-adjusted score. That reading comes from the vector in the advisory (it includes the exploit maturity field), not from an explanation Palo Alto gives, so treat it as my interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is "attackers can execute code as root" accurate?
&lt;/h2&gt;

&lt;p&gt;Yes, with a condition that the headline drops: the attacker must already be an authenticated administrator who can reach Panorama over the network. This is not an unauthenticated attack from the internet. Its practical meaning is privilege escalation: a user who can log in as an administrator, a stolen admin credential, or a compromised admin session can turn that access into full control of the underlying operating system, below the level the product normally exposes.&lt;/p&gt;

&lt;p&gt;That matters because Panorama manages other firewalls. Root on a management server can mean visibility into, or influence over, the configuration of every device it manages. I'm describing the general role of Panorama here, not a confirmed attack path for this CVE, and the advisory doesn't discuss one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Affected and fixed versions
&lt;/h2&gt;

&lt;p&gt;The advisory lists fixed releases per branch. Find your Panorama's branch, then check you are at or above the fixed hotfix for it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Fixed in (depending on your maintenance release)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;12.2&lt;/td&gt;
&lt;td&gt;12.2.4-h2 or later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12.1&lt;/td&gt;
&lt;td&gt;12.1.4-h14, 12.1.7-h12, or 12.1.10-h4 or later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11.2&lt;/td&gt;
&lt;td&gt;11.2.4-h24, 11.2.7-h24, 11.2.10-h18, 11.2.13-h5, or 11.2.16-h2 or later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11.1&lt;/td&gt;
&lt;td&gt;11.1.4-h39, 11.1.6-h41, 11.1.7-h13, 11.1.10-h37, 11.1.13-h17, or 11.1.16-h5 or later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10.2&lt;/td&gt;
&lt;td&gt;10.2.7-h40, 10.2.10-h43, 10.2.13-h28, 10.2.16-h13, or 10.2.18-h14 or later&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Unsupported older versions have no fix; the advisory says to upgrade to a supported fixed release. Read the way hotfix versions work: &lt;code&gt;11.2.7-h24&lt;/code&gt; fixes the 11.2.7 line, and it does not mean &lt;code&gt;11.2.7-h10&lt;/code&gt; is fixed. The "or later" applies within a line, so match your maintenance release first. Where it's ambiguous, trust the advisory page over any table, including this one.&lt;/p&gt;

&lt;p&gt;On the Panorama CLI, the installed version can be read with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;show system info | match sw-version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the standard PAN-OS way to display the software version, but I didn't run it against a device. The web interface shows the same value on the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduce exposure while you schedule the upgrade
&lt;/h2&gt;

&lt;p&gt;The advisory states that restricting management access to trusted internal IP addresses significantly reduces the risk, and gives a lower score when access goes through a jump box. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never expose Panorama's management interface to the internet or to general user networks.&lt;/li&gt;
&lt;li&gt;Allow management only from a small set of internal addresses or a dedicated jump host. In the PAN-OS web interface this is the management interface's permitted IP addresses setting. Check Palo Alto's documentation for the exact location in your version, as I haven't verified the menu path.&lt;/li&gt;
&lt;li&gt;Reduce the number of administrator accounts, remove stale ones and require MFA where you can. The flaw needs an administrator, so every admin account is part of the attack surface.&lt;/li&gt;
&lt;li&gt;Palo Alto notes that customers with a Threat Prevention subscription get limited coverage from Threat IDs &lt;code&gt;510045&lt;/code&gt; and &lt;code&gt;510048&lt;/code&gt;, and that this requires SSL decryption. It is partial coverage, not a substitute for patching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The advisory says access restriction reduces risk but doesn't replace the update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't mix it up with CVE-2026-0322
&lt;/h2&gt;

&lt;p&gt;The same weeks brought a second, better-known PAN-OS flaw:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;CVE-2026-0322&lt;/th&gt;
&lt;th&gt;CVE-2026-0330&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;PA-Series and VM-Series firewalls, and Panorama&lt;/td&gt;
&lt;td&gt;Panorama only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Impact&lt;/td&gt;
&lt;td&gt;Authenticated RCE as root on the management interface&lt;/td&gt;
&lt;td&gt;Authenticated RCE as root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVSS&lt;/td&gt;
&lt;td&gt;8.6 (as reported)&lt;/td&gt;
&lt;td&gt;CVSS-B 8.6, CVSS-BT 6.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exploited&lt;/td&gt;
&lt;td&gt;Yes: "very limited exploitation" reported; added to CISA's KEV catalog&lt;/td&gt;
&lt;td&gt;Not known to be&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patched&lt;/td&gt;
&lt;td&gt;15 October 2026&lt;/td&gt;
&lt;td&gt;11 November 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 0322 details come from The Hacker News coverage, not the vendor page, which I couldn't open. If you patched for 0322 in October, you may still need the new Panorama release for 0330. The fixed hotfix numbers differ.&lt;/p&gt;

&lt;p&gt;The Hacker News also lists three IP addresses that Palo Alto assessed as linked to the 0322 activity: &lt;code&gt;66.42.72.14&lt;/code&gt;, &lt;code&gt;208.83.239.114&lt;/code&gt; and &lt;code&gt;149.248.47.234&lt;/code&gt;. If you keep management-interface access logs, a quick look is cheap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;grep -E -w &lt;span class="s1"&gt;'66\.42\.72\.14|208\.83\.239\.114|149\.248\.47\.234'&lt;/span&gt; /path/to/your/access.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hits don't mean you are clean (attackers change addresses), and these are not indicators for CVE-2026-0330. The &lt;code&gt;-w&lt;/code&gt; flag makes &lt;code&gt;66.42.72.14&lt;/code&gt; not match &lt;code&gt;66.42.72.145&lt;/code&gt;. I tested the command against a sample log.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sensible order of operations
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Inventory every Panorama (physical and virtual) and its PAN-OS version.&lt;/li&gt;
&lt;li&gt;Check that none are reachable from untrusted networks, and fix that first. It takes minutes.&lt;/li&gt;
&lt;li&gt;Schedule upgrades to the fixed release for each branch, starting with the most exposed.&lt;/li&gt;
&lt;li&gt;Review administrator accounts, API keys and recent admin logins.&lt;/li&gt;
&lt;li&gt;After the upgrade, confirm the new version and that managed devices still check in.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Assuming "medium" means "can wait" on a device with root-level impact. A moderate score reflects the admin requirement and no known exploitation, not harmlessness.&lt;/li&gt;
&lt;li&gt;Confusing the two CVEs, and patching only one.&lt;/li&gt;
&lt;li&gt;Checking firewalls but forgetting Panorama.&lt;/li&gt;
&lt;li&gt;Applying the wrong hotfix for your maintenance line.&lt;/li&gt;
&lt;li&gt;Treating a management interface behind a VPN as safe without limiting who can log in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does CVE-2026-0330 affect my PA-Series firewall?
&lt;/h3&gt;

&lt;p&gt;According to the advisory, standalone PAN-OS firewalls, Cloud NGFW and Prisma Access are not affected by this CVE. CVE-2026-0322 is the one that covers firewalls.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it exploited in the wild?
&lt;/h3&gt;

&lt;p&gt;Palo Alto says it is not aware of any malicious exploitation of CVE-2026-0330. That can change, so check the advisory page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can an unauthenticated attacker use it?
&lt;/h3&gt;

&lt;p&gt;The advisory requires an authenticated administrator with network access.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a workaround?
&lt;/h3&gt;

&lt;p&gt;None known. Restricting management access lowers risk but does not fix the flaw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/chrome-zero-day-cve-2026-125350-update-check" rel="noopener noreferrer"&gt;Chrome Zero-Day CVE-2026-125350: What It Is, Who Is Affected and How to Update&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/cursor-claude-code-extension-backdoor-what-to-check" rel="noopener noreferrer"&gt;Malicious Claude Code Extension in the Cursor Marketplace: What Happened and How to Check Your Machine&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://security.paloaltonetworks.com/CVE-2026-0330" rel="noopener noreferrer"&gt;Palo Alto Networks: CVE-2026-0330 advisory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cybersecuritynews.com/palo-alto-networks-pan-os-rce-vulnerability/" rel="noopener noreferrer"&gt;Cyber Security News: Palo Alto Networks PAN-OS RCE Vulnerability&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thehackernews.com/2026/10/palo-alto-networks-patches-pan-os.html" rel="noopener noreferrer"&gt;The Hacker News: Palo Alto Networks patches PAN-OS authenticated RCE flaw (CVE-2026-0322)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/palo-alto-pan-os-cve-2026-0330-panorama-rce" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>paloalto</category>
      <category>panos</category>
      <category>panorama</category>
    </item>
    <item>
      <title>Chrome Zero-Day CVE-2026-125350: Update and Check</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Sat, 14 Nov 2026 05:57:57 +0000</pubDate>
      <link>https://dev.to/devtalk94/chrome-zero-day-cve-2026-125350-update-and-check-2kkn</link>
      <guid>https://dev.to/devtalk94/chrome-zero-day-cve-2026-125350-update-and-check-2kkn</guid>
      <description>&lt;p&gt;Google has patched another Chrome vulnerability that attackers were already exploiting. It is tracked as &lt;strong&gt;CVE-2026-125350&lt;/strong&gt;, a use-after-free bug in Chrome's XML component, and the fix shipped on 9 November 2026. If your browser is older than the fixed build, a web page can be enough to trigger it.&lt;/p&gt;

&lt;p&gt;The headlines say "execute malicious code remotely", which is true but easy to over-read. This article explains what has actually been confirmed, what has not, which versions are safe, and how to check every Chromium-based browser you and your team run, including the ones that don't look like browsers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bug:&lt;/strong&gt; CVE-2026-125350, use-after-free (CWE-416) in Chrome's XML component, rated High by Chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploited:&lt;/strong&gt; Google says it is aware that an exploit for this CVE exists in the wild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in:&lt;/strong&gt; Stable 157.0.8103.50 (Linux) and 157.0.8103.50/.51 (Windows, macOS); Extended Stable 156.0.8078.96.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your action:&lt;/strong&gt; update, then &lt;strong&gt;relaunch&lt;/strong&gt; the browser. The fix does not apply until the restart.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is confirmed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE&lt;/td&gt;
&lt;td&gt;CVE-2026-125350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component and type&lt;/td&gt;
&lt;td&gt;XML, use-after-free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported&lt;/td&gt;
&lt;td&gt;6 November 2026, by an anonymous researcher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed and disclosed&lt;/td&gt;
&lt;td&gt;9 November 2026 (Chrome Stable channel update)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CISA KEV&lt;/td&gt;
&lt;td&gt;Added 10 November 2026, with a due date of 24 November for US federal civilian agencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Severity&lt;/td&gt;
&lt;td&gt;High (Chrome). CVSS 3.1 base score 8.8, scored by CISA, per two security news sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected&lt;/td&gt;
&lt;td&gt;Chrome builds before 157.0.8103.50, per the CVE record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same release&lt;/td&gt;
&lt;td&gt;Nine security fixes in total. Another XML use-after-free, CVE-2026-125346, is in the same update, but the in-the-wild statement applies only to CVE-2026-125350&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What "remote code execution" means here
&lt;/h2&gt;

&lt;p&gt;This is where the headlines run ahead of the evidence. The CISA catalog entry, as quoted by one write-up, describes a remote attacker running code &lt;strong&gt;inside the sandbox&lt;/strong&gt; through a crafted web page. Chrome's sandbox is a separate layer that limits what a compromised renderer can do to the rest of the machine. A sandbox escape or full device compromise has not been publicly confirmed for this bug.&lt;/p&gt;

&lt;p&gt;That is still serious. Real attacks on browsers are usually chains: one bug to get code running in the renderer, and a second one to break out. A bug exploited in the wild is often the first link of such a chain, though nobody has published what the second link was here. Treat the missing information as unknown, not as a reason to wait.&lt;/p&gt;

&lt;p&gt;It also needs a user to visit a page. No account, no credentials, no prior access: just a link, an ad, a compromised site or an embedded frame. Google and CISA have not said who is using the exploit or against whom, so don't assume you are not a target because you are not a high-profile one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a use-after-free bug is dangerous
&lt;/h2&gt;

&lt;p&gt;A use-after-free happens when code keeps using memory after it has been released. If an attacker can control what gets placed in that freed memory, the program may end up reading or running attacker-chosen data. Browsers parse untrusted input constantly, in a language (C++) where this class of mistake is possible, so they see a steady supply of such bugs. That is why Chrome security updates arrive so often, and why running an old build is the real risk, not any single CVE.&lt;/p&gt;

&lt;p&gt;BleepingComputer's headline calls this the eighth Chrome zero-day exploited in attacks this year. I could only read the headline, not the article, so I'm quoting it as reported.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your version in one minute
&lt;/h2&gt;

&lt;p&gt;The fastest check is the browser itself: open &lt;code&gt;chrome://version&lt;/code&gt;, or go to Help, then About Google Chrome, which also triggers the update download. Look for &lt;code&gt;157.0.8103.50&lt;/code&gt; or newer.&lt;/p&gt;

&lt;p&gt;From a terminal:&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;# Linux&lt;/span&gt;
google-chrome --version

&lt;span class="c"&gt;# macOS&lt;/span&gt;
&lt;span class="s2"&gt;"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"&lt;/span&gt; --version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Windows (default install path)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Get&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Item&lt;/span&gt; &lt;span class="s2"&gt;"C:\Program Files\Google\Chrome\Application\chrome.exe"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VersionInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ProductVersion&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To compare it against the fixed build in a script (a fleet audit, a CI image check), here is a small helper. It accepts either the whole version string or just the number:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# usage: ./chk.sh "&amp;lt;version output from the browser&amp;gt;"&lt;/span&gt;
&lt;span class="c"&gt;# Fixed builds for CVE-2026-125350: Stable 157.0.8103.50 or newer,&lt;/span&gt;
&lt;span class="c"&gt;# Extended Stable 156.0.8078.96 or newer.&lt;/span&gt;
&lt;span class="nv"&gt;CUR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(echo "&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;" | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; -z &lt;span class="s2"&gt;"$CUR"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no version found in: $1"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 2&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
ge&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$(printf '%s\n%s\n' "&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;" | sort -V | head -1)"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"$2"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;MAJOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"${CUR%%.*}"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$MAJOR"&lt;/span&gt; -ge 157 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ge &lt;span class="s2"&gt;"$CUR"&lt;/span&gt; 157.0.8103.50&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$CUR: has the fix"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$MAJOR"&lt;/span&gt; -eq 156 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ge &lt;span class="s2"&gt;"$CUR"&lt;/span&gt; 156.0.8078.96&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$CUR: has the fix (Extended Stable line)"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$MAJOR"&lt;/span&gt; -gt 157 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$CUR: newer major, assume fixed"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$CUR: older than the fixed builds, update"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./chk.sh "Google Chrome 156.0.8078.96"
156.0.8078.96: has the fix (Extended Stable line)
$ ./chk.sh "157.0.8103.9"
157.0.8103.9: older than the fixed builds, update
$ ./chk.sh "153.0.8010.12"
153.0.8010.12: older than the fixed builds, update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comparison uses &lt;code&gt;sort -V&lt;/code&gt;, which compares dotted numbers correctly, so &lt;code&gt;157.0.8103.9&lt;/code&gt; is treated as older than &lt;code&gt;157.0.8103.50&lt;/code&gt;. A plain string comparison would get that wrong. I tested the script against those cases, including a version-less input, which exits with an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to update
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Desktop Chrome (Windows, macOS):&lt;/strong&gt; Help, About Google Chrome, then &lt;strong&gt;Relaunch&lt;/strong&gt;. Chrome downloads updates in the background, but the fix takes effect only after a restart. A browser that has run for weeks with a pending update is still vulnerable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; use the package manager. For Google's apt repository, that is &lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install --only-upgrade google-chrome-stable&lt;/code&gt;, then restart the browser. Use the equivalent command for your distribution if you installed Chromium from a distro package, and check that its maintainers have shipped the fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; update through Google Play. One report lists the Android build as 157.0.8103.50, but the Android advisory does not list this CVE individually, so confirm the version on your own device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed fleets:&lt;/strong&gt; use your endpoint management tool to read installed versions, and consider Chrome's enterprise relaunch notification policies so users are not left on a pending update indefinitely. Check the current policy documentation for exact names and behavior, since I did not test them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Other Chromium browsers and apps
&lt;/h2&gt;

&lt;p&gt;The vulnerable code lives in Chromium, the open-source engine, so Edge, Brave, Opera, Vivaldi and others may be affected until they ship a build based on the fix. Each vendor patches on its own schedule. Two data points from the sources I read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft's Edge release notes of 9 November said it was aware of exploits in the wild and was working on a fix, without naming the CVE, according to a secondary write-up. Check the Edge security notes for the current fixed build.&lt;/li&gt;
&lt;li&gt;I did not find a confirmed fixed version for Opera, Vivaldi or Brave in what I read. Look up each vendor's own advisory rather than assuming.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As developers we also run Chromium in places that don't look like browsers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Playwright, Puppeteer and Selenium test browsers.&lt;/strong&gt; They download pinned Chromium builds. In my test environment, Playwright's bundled Chromium reported &lt;code&gt;153.0.8010.12&lt;/code&gt;, which is older than the fixed build. By the CVE's affected range that means it does not contain this fix. I did not test whether the exploit works against it. If your CI browses untrusted pages (scraping, link checking, screenshot services), update the browser package and the cached browser downloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron apps.&lt;/strong&gt; Each app bundles its own Chromium. Whether an app is exposed depends on its Electron version and on whether it loads untrusted content. Look at the vendor's release notes rather than your installed Chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headless Chrome in containers.&lt;/strong&gt; A Docker image built months ago keeps the browser version it was built with. Rebuild images that include Chrome or Chromium.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you can't patch right away
&lt;/h2&gt;

&lt;p&gt;Patching is the fix. Everything below only reduces exposure, and none of it replaces updating:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Close long-running browser sessions and restart the browser, so a downloaded update is actually applied.&lt;/li&gt;
&lt;li&gt;Avoid opening untrusted links from email and chat on unpatched machines.&lt;/li&gt;
&lt;li&gt;Keep browser isolation features and the sandbox enabled. Don't launch Chrome with &lt;code&gt;--no-sandbox&lt;/code&gt; outside a throwaway container, and if CI needs it, keep that CI away from untrusted pages.&lt;/li&gt;
&lt;li&gt;Restrict unapproved extensions, and use DNS or web-gateway filtering if your organization has them.&lt;/li&gt;
&lt;li&gt;Watch endpoint telemetry for unusual browser child processes, repeated browser crashes and odd outbound connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Updating without relaunching.&lt;/li&gt;
&lt;li&gt;Checking only Chrome and forgetting Edge, Brave, Electron apps and CI browsers.&lt;/li&gt;
&lt;li&gt;Assuming a sandboxed bug is harmless, or assuming it means total compromise. Neither is established.&lt;/li&gt;
&lt;li&gt;Waiting for details of the exploit before patching.&lt;/li&gt;
&lt;li&gt;Reading the CISA deadline as applying to everyone. The 24 November date is for US federal civilian agencies; for everyone else it is a useful priority signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is CVE-2026-125350 being exploited right now?
&lt;/h3&gt;

&lt;p&gt;Google says it is aware that an exploit exists in the wild. It has not disclosed who is using it or how widely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to do anything if Chrome updates itself?
&lt;/h3&gt;

&lt;p&gt;Yes: relaunch. Check &lt;code&gt;chrome://version&lt;/code&gt; afterwards and confirm the number is at least &lt;code&gt;157.0.8103.50&lt;/code&gt; (or &lt;code&gt;156.0.8078.96&lt;/code&gt; on Extended Stable).&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it affect Firefox or Safari?
&lt;/h3&gt;

&lt;p&gt;The sources I read describe a Chrome and Chromium bug. I did not find anything saying Firefox or Safari are affected, but I did not test them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Did the attackers escape the sandbox?
&lt;/h3&gt;

&lt;p&gt;Not publicly confirmed. Code execution in the sandbox is the confirmed description.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know if I was attacked?
&lt;/h3&gt;

&lt;p&gt;There are no public indicators of compromise that I could find. Google has not published any attacker details. The practical response is to patch and review endpoint telemetry for unusual browser behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/cursor-claude-code-extension-backdoor-what-to-check" rel="noopener noreferrer"&gt;Malicious Claude Code Extension in the Cursor Marketplace: What Happened and How to Check Your Machine&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cybersecuritynews.com/google-chrome-0-day-exploited/" rel="noopener noreferrer"&gt;Cyber Security News: Google Chrome 0-Day Vulnerability in the Wild to Execute Malicious Code Remotely&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://socprime.com/blog/cve-2026-125350-chrome-xml-zero-day-exploited-in-the-wild/" rel="noopener noreferrer"&gt;SOC Prime: CVE-2026-125350, Chrome XML Zero-Day Exploited in the Wild&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.esecurityplanet.com/news/news-google-chrome-zero-day-patch/" rel="noopener noreferrer"&gt;eSecurity Planet: Google Patches Exploited Chrome Zero-Day&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://braindetox.kr/en/posts/chrome_cve_2026_125350_kev.html" rel="noopener noreferrer"&gt;braindetox.kr: Chrome CVE-2026-125350 in KEV&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.bleepingcomputer.com/news/security/google-patches-eighth-chrome-zero-day-exploited-in-attacks-this-year/" rel="noopener noreferrer"&gt;BleepingComputer: Google patches eighth Chrome zero-day exploited in attacks this year&lt;/a&gt; (headline only)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/chrome-zero-day-cve-2026-125350-update-check" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>chrome</category>
      <category>zeroday</category>
      <category>cve</category>
    </item>
    <item>
      <title>Malicious Claude Code Extension in Cursor: Check Your Machine</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Sat, 14 Nov 2026 05:20:14 +0000</pubDate>
      <link>https://dev.to/devtalk94/malicious-claude-code-extension-in-cursor-check-your-machine-1b05</link>
      <guid>https://dev.to/devtalk94/malicious-claude-code-extension-in-cursor-check-your-machine-1b05</guid>
      <description>&lt;p&gt;If you use Cursor and had the Claude Code extension installed on 29 October 2026, there is a real chance you ran a backdoored copy of it. Headlines have described this as "Anthropic hacked", and that is not what the vendor reports say. This post separates what has been confirmed from what has been claimed, and gives you a short, practical checklist.&lt;/p&gt;

&lt;p&gt;The short version: a modified build of the extension sat in &lt;strong&gt;Cursor's&lt;/strong&gt; extension marketplace for about 11 hours. It tried to give an attacker SSH access to the developer's machine. Cursor says Anthropic did not publish it and that its product was not vulnerable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is confirmed
&lt;/h2&gt;

&lt;p&gt;These points come from Cursor's own notices, with Sophos and other security vendors repeating them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Affected versions:&lt;/strong&gt; &lt;code&gt;Anthropic.claude-code&lt;/code&gt; 2.1.312 and 2.1.313, on Windows and macOS, from the Cursor VS Code extension marketplace. Cursor's first notice named 2.1.313; it was updated on 30 October to include 2.1.312.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Window:&lt;/strong&gt; about 09:27 to 20:35 UTC on 29 October 2026 (Cursor gives it as roughly 2:27 am to 1:35 pm Pacific).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exposure:&lt;/strong&gt; Cursor estimates up to 5,000 users. It describes these as users who requested the specific cached versions that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who built it:&lt;/strong&gt; Cursor states that the malicious versions "were not developed by Anthropic."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Was Anthropic hacked?
&lt;/h2&gt;

&lt;p&gt;Based on Cursor's incident update (13 November), no. Cursor says an attacker used a credential taken from Cursor's own internal data to place unauthorized copies of the extension on the cache that serves its marketplace. It says Anthropic did not publish this copy, this was not a vulnerability in Anthropic's product, and the upstream source, Open VSX, never had the malware.&lt;/p&gt;

&lt;p&gt;The same update describes how the attacker got that credential: an access key Cursor had issued to a third-party inference provider, Fireworks, which Cursor says was exposed by Fireworks. The attacker used it to copy data from a read-only storage bucket, and some stored traces contained unredacted credentials. I have only Cursor's account of this chain. Treat it as the vendor's statement, not independent confirmation.&lt;/p&gt;

&lt;p&gt;I could not open the Doublepulsar article that prompted this post, so I can't say how it frames the incident. If you have read it, compare its claims against Cursor's notices linked at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the malicious build does
&lt;/h2&gt;

&lt;p&gt;According to Cursor, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Collects host and user details (hostname, username, OS, architecture, home directory, environment variable &lt;em&gt;names&lt;/em&gt;, working directories, Git email and public IP) and sends them to an attacker's server.&lt;/li&gt;
&lt;li&gt;Tries to add an attacker-controlled SSH key, enable SSH and open a reverse tunnel. Cursor says this would usually fail without administrator rights.&lt;/li&gt;
&lt;li&gt;Tries to persist through a LaunchAgent on macOS or a scheduled task on Windows, retrying about every 60 seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note the word "tries". Sophos, citing an analysis by Phorion, says coding errors made SSH enablement fail on most macOS and Windows hosts. That limits the damage on many machines, but it is not a reason to skip the checks. Environment variable names alone can tell an attacker what cloud providers and services you use.&lt;/p&gt;

&lt;p&gt;Cursor says the attacker's server has been taken offline. That means blocking the IP is of limited use now, but its logs and your own are still useful for working out whether a machine connected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check whether you were affected
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Did you have the extension in Cursor on 29 October?&lt;/strong&gt; Look at your installed extensions and the version. If you only use Claude Code in VS Code from Microsoft's marketplace, or in a terminal, these notices do not cover you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for the planted SSH key.&lt;/strong&gt; Cursor's notice names the key comment &lt;code&gt;svc-telemetry&lt;/code&gt;. On macOS or Linux:&lt;code&gt;grep -n 'svc-telemetry' ~/.ssh/authorized_keys&lt;/code&gt; No output (or "No such file") means that key is not there. A matching line means the key was added.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at running processes and persistence.&lt;/strong&gt; Cursor provides platform-specific remediation scripts (&lt;code&gt;remediate-macos.sh&lt;/code&gt; and &lt;code&gt;remediate-windows.ps1&lt;/code&gt;) and the full list of indicators. Use those rather than a checklist copied from a blog, including this one, because indicators were updated after the first notice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On Windows, Cursor gives an equivalent PowerShell check for the user's and ProgramData's &lt;code&gt;authorized_keys&lt;/code&gt; files. Get it from the notice itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you find something
&lt;/h2&gt;

&lt;p&gt;Cursor's advice, in order of priority:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolate the machine&lt;/strong&gt; if there are signs the code ran or a backdoor was installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not rely on uninstalling the extension.&lt;/strong&gt; Cursor warns that uninstalling may not be enough because standalone persistence scripts can keep running. Remove the unauthorized key, the persistence entries and the tunnel processes. Consider rebuilding the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate credentials and sessions&lt;/strong&gt; that were reachable from that machine, doing it from a trusted system. Start with SSH keys, cloud provider keys, API tokens and anything stored in environment variables or &lt;code&gt;.env&lt;/code&gt; files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have no sign of the key and no sign of the persistence entries, a precautionary rotation of the most sensitive credentials is still cheap compared with the alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons for the rest of us
&lt;/h2&gt;

&lt;p&gt;These are my recommendations, not findings from the incident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extensions run with your privileges.&lt;/strong&gt; They can read your environment, your SSH directory and your project secrets. Treat an editor extension update like a dependency update, not like a theme change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-update is a trade-off.&lt;/strong&gt; It gets you security fixes quickly, but it also delivers a bad build quickly. For machines that hold production credentials, consider managing extension versions deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep production secrets off developer laptops.&lt;/strong&gt; Short-lived credentials, a secrets manager and separate deploy keys limit what a compromised editor can reach. Our &lt;a href="https://dev-talk.com/post/github-actions-laravel-deploy-ssh-releases-rollback" rel="noopener noreferrer"&gt;SSH deploy article&lt;/a&gt; uses a dedicated deploy key for that reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch your&lt;/strong&gt;&lt;code&gt;authorized_keys&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; A new key you didn't add is a high-signal alert on any machine, and it is simple to monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supply-chain risk follows the tooling.&lt;/strong&gt; A malicious build under a trusted vendor's name is exactly the case where checking the publisher alone is not enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes during cleanup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Uninstalling the extension and stopping there.&lt;/li&gt;
&lt;li&gt;Rotating credentials from the possibly compromised machine.&lt;/li&gt;
&lt;li&gt;Assuming you are safe because you use the Microsoft marketplace. The notices concern the Cursor marketplace only; check where your copy came from.&lt;/li&gt;
&lt;li&gt;Searching only for one version number. Cursor extended the affected versions after its first notice.&lt;/li&gt;
&lt;li&gt;Relying on the attacker IP alone, since the server was taken down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/laravel-production-mistakes" rel="noopener noreferrer"&gt;5 Laravel Production Mistakes That Pass Every Local Test&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does this affect the Claude Code CLI or the extension in VS Code?
&lt;/h3&gt;

&lt;p&gt;The notices I read describe the Cursor marketplace copy only. Cursor says there is no evidence that other versions of the extension or the official distribution are affected. I did not test any of this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it a remote access trojan?
&lt;/h3&gt;

&lt;p&gt;Cursor describes an attempt to add an SSH key and open a reverse tunnel, which would give an attacker remote access if it worked. I haven't seen the term "remote access trojan" in the vendor notices I read, so I use their wording.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which version should I be on now?
&lt;/h3&gt;

&lt;p&gt;Follow Cursor's current notice. One third-party advisory I read mentioned 2.1.314 as the fixed version, but I couldn't verify that, and updating alone does not remove anything already planted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cursor.com/blog/claude-code-extension-security-notice" rel="noopener noreferrer"&gt;Cursor: security notice, malicious Anthropic.claude-code versions in the Cursor marketplace&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/blog/security-incident-update" rel="noopener noreferrer"&gt;Cursor: an update on our recent security incident&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.sophos.com/en-us/blog/malicious-claude-code-extension-distributed-through-cursor-marketplace" rel="noopener noreferrer"&gt;Sophos: malicious Claude Code extension distributed through Cursor marketplace&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/cursor-claude-code-extension-backdoor-what-to-check" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>supplychain</category>
      <category>cursor</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>10 Common API Design Mistakes</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Sat, 14 Nov 2026 03:15:18 +0000</pubDate>
      <link>https://dev.to/devtalk94/10-common-api-design-mistakes-5cej</link>
      <guid>https://dev.to/devtalk94/10-common-api-design-mistakes-5cej</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 9 November 2026. See the&lt;/em&gt;&lt;em&gt;update log&lt;/em&gt;&lt;em&gt;at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most API problems don't show up on launch day. They show up months later, when a mobile app can't be updated because a field changed type, when a retry double-charges a customer, when a support ticket turns out to be one user reading another user's order. The code worked. The &lt;em&gt;design&lt;/em&gt; was the bug, and by then a hundred clients depend on it.&lt;/p&gt;

&lt;p&gt;This article covers ten mistakes that I see repeated in REST-style JSON APIs, with a small demo server behind each one so the symptom is real output rather than a claim. Every mistake comes with the fix, and where the fix is written down in a standard (HTTP semantics, Problem Details, the deprecation headers) I checked the standard instead of quoting from memory. The demo servers are plain Node, so nothing here depends on a framework; the PHP/Laravel snippets show how the fixes map to the stack many readers use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ten at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;The fix in one line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Returning &lt;code&gt;200 OK&lt;/code&gt; for errors&lt;/td&gt;
&lt;td&gt;Use the status code, let clients branch on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;A different error shape on every endpoint&lt;/td&gt;
&lt;td&gt;One format, ideally &lt;code&gt;application/problem+json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Ignoring HTTP method semantics&lt;/td&gt;
&lt;td&gt;Match method to safe / idempotent behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Unbounded lists and offset pagination&lt;/td&gt;
&lt;td&gt;Always paginate; use cursors for changing data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Retries that create duplicates&lt;/td&gt;
&lt;td&gt;Idempotency keys for non-idempotent writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Breaking changes with no warning&lt;/td&gt;
&lt;td&gt;Additive changes, plus &lt;code&gt;Deprecation&lt;/code&gt; and &lt;code&gt;Sunset&lt;/code&gt; headers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;No object-level authorization&lt;/td&gt;
&lt;td&gt;Check ownership on every object access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Exposing and accepting whole models&lt;/td&gt;
&lt;td&gt;Explicit response shapes and allow-listed input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;No rate limiting&lt;/td&gt;
&lt;td&gt;Limit, and answer &lt;code&gt;429&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Sloppy data conventions: money, dates, naming&lt;/td&gt;
&lt;td&gt;Integer minor units, RFC 3339 timestamps, one naming style&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Returning 200 OK for errors
&lt;/h2&gt;

&lt;p&gt;It looks harmless and it is one of the most expensive habits. The endpoint returns &lt;code&gt;200&lt;/code&gt; and puts the failure in the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -si localhost:4040/bad/users/999
HTTP/1.1 200 OK
Content-Type: application/json

{"success":false,"error":"User not found"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything between your server and the client's code treats that as a success: HTTP clients that only throw on 4xx/5xx, retry logic, monitoring that counts error rates from status codes, caches, and the browser's network panel. Your dashboards say the API is 100% healthy while users see "not found". Every client team also has to learn your private convention for finding the real outcome.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -si localhost:4040/good/users/999
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{"type":"https://api.example.com/problems/user-not-found","title":"User not found","status":404,"detail":"No user with id 999.","instance":"/good/users/999"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status codes you need most are all defined in RFC 9110: &lt;code&gt;201 Created&lt;/code&gt; for a successful create, &lt;code&gt;204 No Content&lt;/code&gt; for success without a body, &lt;code&gt;400&lt;/code&gt; for a malformed request, &lt;code&gt;401&lt;/code&gt; and &lt;code&gt;403&lt;/code&gt; for authentication and permission problems, &lt;code&gt;404&lt;/code&gt;, &lt;code&gt;409 Conflict&lt;/code&gt;, and &lt;code&gt;422&lt;/code&gt; for a well-formed request that fails validation. The spec's current name for 422 is "Unprocessable Content". (My Node server still prints the older phrase "Unprocessable Entity" in the status line, which is only a label; the number is what matters.) Rate limiting uses &lt;code&gt;429&lt;/code&gt;, which comes from RFC 6585, and gets its own section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A different error format on every endpoint
&lt;/h2&gt;

&lt;p&gt;Even with correct status codes, clients suffer when one endpoint returns &lt;code&gt;{"error": "..."}&lt;/code&gt;, another &lt;code&gt;{"message": "...", "code": 12}&lt;/code&gt;, and a third a bare string. Pick one shape. You don't need to invent it: RFC 9457, "Problem Details for HTTP APIs", defines one, and it replaced RFC 7807.&lt;/p&gt;

&lt;p&gt;Per the RFC, the media type is &lt;code&gt;application/problem+json&lt;/code&gt; and the standard members are all optional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;type&lt;/code&gt;: a URI that identifies the kind of problem. If it is absent, clients assume &lt;code&gt;about:blank&lt;/code&gt;, meaning "nothing beyond the HTTP status".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt;: the HTTP status code. Generators must send the same code in the actual response; consumers treat the member as advisory, since a proxy could change the real status.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;title&lt;/code&gt;: a short summary of the problem type that stays the same across occurrences.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;detail&lt;/code&gt;: an explanation of this specific occurrence, meant to help the client fix the problem.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;instance&lt;/code&gt;: a URI identifying this particular occurrence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can add your own members, such as an &lt;code&gt;errors&lt;/code&gt; array for per-field validation failures. Clients must ignore extension members they don't recognize, so adding them later doesn't break anyone. Keep &lt;code&gt;detail&lt;/code&gt; useful to the caller and free of stack traces and SQL; the RFC says it is meant to help the client fix the problem rather than to provide debugging information.&lt;/p&gt;

&lt;p&gt;Whatever your framework returns by default for a 404 or a validation failure, call two or three endpoints with &lt;code&gt;curl -i&lt;/code&gt; and look at the shapes. If they differ, that is the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Ignoring HTTP method semantics
&lt;/h2&gt;

&lt;p&gt;Two properties from RFC 9110 matter in practice. A &lt;em&gt;safe&lt;/em&gt; method is essentially read-only: of the methods defined there, GET, HEAD, OPTIONS and TRACE are safe. An &lt;em&gt;idempotent&lt;/em&gt; method has the same intended effect whether it is sent once or many times: PUT, DELETE and the safe methods are idempotent. POST is not on that list.&lt;/p&gt;

&lt;p&gt;Why you care: clients, proxies and browsers act on these properties. A client may retry an idempotent request automatically after a connection failure, and a crawler or link prefetcher can follow a GET without anyone clicking. So these designs go wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /orders/42/cancel&lt;/code&gt; or &lt;code&gt;GET /users/7/delete&lt;/code&gt;: a state change behind a safe method. Anything that fetches the URL, including a prefetcher or a monitoring probe, triggers it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /getUser&lt;/code&gt;, &lt;code&gt;POST /updateUser&lt;/code&gt;, &lt;code&gt;POST /deleteUser&lt;/code&gt;: everything funneled through POST, so no caching of reads and no automatic retry of anything.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;PUT&lt;/code&gt; that appends instead of replacing, so a retry applies the change twice and the "idempotent" label lies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The straightforward mapping: &lt;code&gt;GET /orders/42&lt;/code&gt; reads, &lt;code&gt;POST /orders&lt;/code&gt; creates, &lt;code&gt;PUT /orders/42&lt;/code&gt; replaces, &lt;code&gt;DELETE /orders/42&lt;/code&gt; removes. For actions that aren't CRUD, a sub-resource noun usually reads better than a verb in the path, for example &lt;code&gt;POST /orders/42/cancellation&lt;/code&gt;. Don't fight over naming purity; the rule that matters is that the method's promised behavior is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Unbounded lists and offset pagination
&lt;/h2&gt;

&lt;p&gt;The first version of &lt;code&gt;GET /items&lt;/code&gt; returns everything. It works in development with 50 rows and takes the server down with 5 million. Always cap and paginate, even if you only have one client today.&lt;/p&gt;

&lt;p&gt;The second trap is the pagination style. Offset pagination (&lt;code&gt;?limit=3&amp;amp;offset=3&lt;/code&gt;) breaks when data changes between page requests. I fetched page one, deleted item 2, then fetched page two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s "localhost:4040/items?limit=3&amp;amp;offset=0"
{"data":[{"id":1,"name":"item1"},{"id":2,"name":"item2"},{"id":3,"name":"item3"}]}
$ curl -s -X DELETE localhost:4040/items/2        # 204
$ curl -s "localhost:4040/items?limit=3&amp;amp;offset=3"
{"data":[{"id":5,"name":"item5"},{"id":6,"name":"item6"},{"id":7,"name":"item7"}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Item 4 never appeared. Deleting a row on an earlier page shifted everything up by one, and the next offset jumped over it. An insert does the opposite and shows a duplicate. Cursor (keyset) pagination asks for "the items after the last one I saw" and doesn't care what moved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s "localhost:4040/items?limit=3&amp;amp;after=0"
{"data":[{"id":1,...},{"id":2,...},{"id":3,...}],"next_cursor":3}
$ curl -s -X DELETE localhost:4040/items/2        # 204
$ curl -s "localhost:4040/items?limit=3&amp;amp;after=3"
{"data":[{"id":4,"name":"item4"},{"id":5,"name":"item5"},{"id":6,"name":"item6"}],"next_cursor":6}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same sequence, nothing skipped. (I shortened the first page's items with &lt;code&gt;...&lt;/code&gt; for width.) The Laravel documentation makes the same two points: offset pagination "may skip records or show duplicates" on data with frequent writes, and the &lt;code&gt;offset&lt;/code&gt; clause scans through all previously matched rows, so cursor pagination performs better on large tables when the ordering columns are indexed.&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;// Laravel: cursor pagination (the ordering must include a unique column)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&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="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'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;cursorPaginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&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 documented limits of cursor pagination: it only supports next/previous navigation, not "jump to page 40", and it requires ordering on at least one unique column, with no &lt;code&gt;null&lt;/code&gt; values in the ordering columns. If users truly need page numbers (an admin table, say), offset is fine; just know you are trading consistency for convenience. Also set a maximum page size on the server so &lt;code&gt;?limit=100000&lt;/code&gt; is not an option.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Retries that create duplicates
&lt;/h2&gt;

&lt;p&gt;A client sends &lt;code&gt;POST /orders&lt;/code&gt; and the connection drops before the response arrives. Did the order go through? The client can't know, so it retries, and now there may be two orders. Mobile networks, load balancers and impatient users make this routine, not rare. Without protection, the demo server does exactly what you fear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -X POST localhost:4040/orders -d '{"sku":"A1","qty":1}'
{"id":1,"sku":"A1","qty":1}  [201]
$ curl -s -X POST localhost:4040/orders -d '{"sku":"A1","qty":1}'
{"id":2,"sku":"A1","qty":1}  [201]
$ curl -s localhost:4040/orders
{"count":2}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The standard answer is an idempotency key: the client generates a unique value per logical operation and sends it with every retry, and the server remembers the result for that key. The IETF has an Internet-Draft for this, &lt;code&gt;draft-ietf-httpapi-idempotency-key-header&lt;/code&gt;. It is a draft, not an RFC; revision 08 was the latest when I checked, and details may still change. What it currently describes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The header is &lt;code&gt;Idempotency-Key&lt;/code&gt;, a structured-field String, and the draft recommends a UUID or similar random value.&lt;/li&gt;
&lt;li&gt;It targets non-idempotent methods, named as POST and PATCH.&lt;/li&gt;
&lt;li&gt;A request that reuses a key with a different payload should get &lt;code&gt;422&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A retry that arrives while the first request is still processing should get &lt;code&gt;409&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A missing key, for an operation that requires one, should get &lt;code&gt;400&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Expiry is left to the service, which should document its policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My demo follows that behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -si -X POST localhost:4040/orders -H 'Idempotency-Key: "k-123"' -d '{"sku":"A1","qty":1}'
HTTP/1.1 201 Created
{"id":1,"sku":"A1","qty":1}
$ curl -si -X POST localhost:4040/orders -H 'Idempotency-Key: "k-123"' -d '{"sku":"A1","qty":1}'
HTTP/1.1 201 Created
Idempotent-Replayed: true
{"id":1,"sku":"A1","qty":1}
$ curl -s localhost:4040/orders
{"count":1}

$ curl -si -X POST localhost:4040/orders -H 'Idempotency-Key: "k-123"' -d '{"sku":"B2","qty":5}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{"type":"about:blank","title":"Idempotency key reused with a different payload","status":422}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;Idempotent-Replayed&lt;/code&gt; is my own demo header, not part of the draft.) The retry returned the original order and the count stayed at one. A real implementation needs more than my in-memory map:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the key, a hash of the request body, and the response in the database, with a &lt;strong&gt;unique constraint&lt;/strong&gt; on the key (scoped to the user or API client, so one customer can't collide with another).&lt;/li&gt;
&lt;li&gt;Insert the key &lt;em&gt;before&lt;/em&gt; doing the work, so two simultaneous retries can't both pass the check. The loser gets the &lt;code&gt;409&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write the key and the business change in the same transaction, or you can record a key for work that rolled back.&lt;/li&gt;
&lt;li&gt;Expire old keys on a schedule you document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not write a Laravel implementation for this article, so there is no copy-paste middleware here; the four rules above are the part that makes it correct, and the storage can be a plain table.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Breaking changes with no warning
&lt;/h2&gt;

&lt;p&gt;Clients you don't control (mobile apps in particular) can't be updated the day you ship. What counts as breaking is mostly a matter of reasoning about what a client has to assume. Adding a new optional response field or a new endpoint is normally safe, as long as clients ignore unknown fields. Removing or renaming a field, changing its type (a number to a string, an object to an array), tightening validation, or changing what a status code means will break someone.&lt;/p&gt;

&lt;p&gt;When you must break something, say so in the response itself. Two standards cover this. RFC 9745 defines a &lt;code&gt;Deprecation&lt;/code&gt; response header whose value is a date written as a structured-field Date, for example &lt;code&gt;@1688169599&lt;/code&gt;, and it can be in the past (already deprecated) or the future (planned). The older RFC 8594 defines &lt;code&gt;Sunset&lt;/code&gt;, the time the resource is expected to stop responding, and RFC 9745 says the Sunset time must not be earlier than the Deprecation date. A &lt;code&gt;Link&lt;/code&gt; header with &lt;code&gt;rel="deprecation"&lt;/code&gt; points to the migration documentation. A response from the demo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -si localhost:4042/v1/report
HTTP/1.1 200 OK
Deprecation: @1788912000
Sunset: Thu, 30 Sep 2027 23:59:59 GMT
Link: &amp;lt;https://api.example.com/docs/migrate-v2&amp;gt;; rel="deprecation"; type="text/html"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the two date formats: &lt;code&gt;Deprecation&lt;/code&gt; is a Unix timestamp prefixed with &lt;code&gt;@&lt;/code&gt; (here 1788912000 is 9 September 2026, 00:00 UTC), while &lt;code&gt;Sunset&lt;/code&gt; uses the classic HTTP date. RFC 9745 itself points out that they differ for historical reasons. I got the weekday wrong on my first draft of that header, which is a good reason to generate these values with code instead of typing them.&lt;/p&gt;

&lt;p&gt;The headers only help if someone reads them. Log which clients still call deprecated endpoints (by API key or user agent) so you can contact them before the sunset date, and keep the old version running until the numbers say it is safe. How you expose versions (a &lt;code&gt;/v1/&lt;/code&gt; path prefix, a header, a media type) is a trade-off with no universally right answer; a path prefix is the simplest to route, cache and debug, so it is a reasonable default.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. No object-level authorization
&lt;/h2&gt;

&lt;p&gt;Authentication tells you who is calling. Authorization has to check, on &lt;em&gt;every request for a specific object&lt;/em&gt;, whether this caller may touch it. The classic mistake is checking only that the token is valid, then loading &lt;code&gt;/orders/{id}&lt;/code&gt; for whatever ID the client sent. This is Broken Object Level Authorization, and it is API1 in the OWASP API Security Top 10 (2023 edition). In the demo, Ann's token reads Bob's order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/bad/orders/11
{"id":11,"owner":2,"total":9900} [200]

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/good/orders/11
{"title":"Not found"} [404]

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/good/orders/999
{"title":"Not found"} [404]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixed version returns the same &lt;code&gt;404&lt;/code&gt; for "exists but isn't yours" and "doesn't exist", so the response doesn't confirm which IDs are real. Whether to use 404 or 403 is a design decision: 403 is more honest and easier to debug but tells an attacker the object exists; 404 hides that. Pick one on purpose and apply it consistently.&lt;/p&gt;

&lt;p&gt;Using sequential integer IDs makes the bug easy to exploit, because the attacker just counts upward, but switching to UUIDs doesn't fix the missing check; it only makes guessing harder. The fix is the check, and the cheapest way to make it hard to forget is to query through the owner:&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;// Scope the lookup to the authenticated user; a foreign id behaves like a missing one&lt;/span&gt;
&lt;span class="nv"&gt;$order&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="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orders&lt;/span&gt;&lt;span class="p"&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="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Or, if you prefer explicit policies (this returns 403 on denial):&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&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="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order&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 same applies to functions, not only objects: an endpoint like &lt;code&gt;DELETE /admin/users/5&lt;/code&gt; needs its own role check (OWASP lists that as API5, Broken Function Level Authorization). For a wider checklist of what to lock down in a Laravel app.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Exposing and accepting whole models
&lt;/h2&gt;

&lt;p&gt;Two mirror-image mistakes share one cause: the API shape is whatever the database model happens to look like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the way out&lt;/strong&gt;, returning the model directly leaks columns nobody meant to publish. In the demo, &lt;code&gt;GET /bad/me&lt;/code&gt; serializes the whole user record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/bad/me
{"id":1,"name":"Ann","email":"ann@example.test","is_admin":false,"password_hash":"$2y$secret"}

$ curl -s -H 'Authorization: Bearer tok-ann' localhost:4041/good/me
{"id":1,"name":"Ann","email":"ann@example.test"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;On the way in&lt;/strong&gt;, copying the request body onto the model is mass assignment. Ann sends one extra field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -X PATCH -H 'Authorization: Bearer tok-ann' -d '{"name":"Ann","is_admin":true}' localhost:4041/good/me
{"ok":true,"is_admin":false}

$ curl -s -X PATCH -H 'Authorization: Bearer tok-ann' -d '{"name":"Ann","is_admin":true}' localhost:4041/bad/me
{"ok":true,"is_admin":true}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the unsafe endpoint she promoted herself to admin. OWASP groups both under API3:2023, Broken Object Property Level Authorization. The fix is the same in both directions: write the shape down. In Laravel that means a Form Request with explicit rules, using only &lt;code&gt;validated()&lt;/code&gt; input, and an API Resource for output:&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/Requests/UpdateProfileRequest.php&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UpdateProfileRequest&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;FormRequest&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="nf"&gt;rules&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="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;'name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sometimes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:100'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sometimes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:255'&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;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// app/Http/Resources/UserResource.php&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserResource&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JsonResource&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="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&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="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;'id'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;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;id&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;email&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;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// controller&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UpdateProfileRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&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="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&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;validated&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserResource&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="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;validated()&lt;/code&gt; returns only the keys that have rules, an &lt;code&gt;is_admin&lt;/code&gt; field in the body is dropped rather than saved. Treat the model's &lt;code&gt;$fillable&lt;/code&gt; list as a second line of defense, not the first. The explicit resource also gives you a stable contract: adding a column to the table doesn't silently add a field to the public API.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. No rate limiting
&lt;/h2&gt;

&lt;p&gt;Without limits, one buggy client in a retry loop, one scraper or one credential-stuffing script can eat the capacity every other user shares. OWASP calls the general problem Unrestricted Resource Consumption (API4:2023). Limits also protect your database and your bill.&lt;/p&gt;

&lt;p&gt;RFC 6585 defines &lt;code&gt;429 Too Many Requests&lt;/code&gt;: the client sent too many requests in a given time. The response should describe the problem and may include a &lt;code&gt;Retry-After&lt;/code&gt; header saying how long to wait. Giving well-behaved clients a number is what turns a limit from a wall into a protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ for i in 1 2 3 4; do curl -s -o /dev/null -w "req $i -&amp;gt; %{http_code}\n" localhost:4042/search; done
req 1 -&amp;gt; 200
req 2 -&amp;gt; 200
req 3 -&amp;gt; 200
req 4 -&amp;gt; 429

$ curl -si localhost:4042/search
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

{"title":"Too Many Requests","status":429,"detail":"Limit is 3 requests per minute."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Laravel, define a named limiter and attach it with the &lt;code&gt;throttle&lt;/code&gt; middleware. This is the example from the framework documentation:&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/Providers/AppServiceProvider.php, in boot()&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api'&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="nc"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&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;by&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="p"&gt;)&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="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;ip&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;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 docs state that Laravel returns a 429 automatically when the limit is exceeded, but they don't list which headers it sends, so run &lt;code&gt;curl -i&lt;/code&gt; against your own app and confirm that &lt;code&gt;Retry-After&lt;/code&gt; is present before you tell client developers they can rely on it. Think about what you key the limit on: per-IP limits punish everyone behind one office or mobile carrier address, while per-user limits don't stop anonymous floods, so most APIs need both. Stricter limits belong on login and password-reset endpoints, and the limit counters should live in a shared store like Redis when you run more than one server, otherwise each server counts separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Sloppy data conventions: money, dates and naming
&lt;/h2&gt;

&lt;p&gt;These are small decisions that are very hard to change later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Money as floating point.&lt;/strong&gt; JSON numbers are doubles in many clients, and binary floating point can't represent most decimal amounts exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node -e 'console.log(19.99*100); console.log(Math.round(19.99*100))'
1998.9999999999998
1999

$ php -r 'var_dump((int)(19.99*100));'
int(1998)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That PHP line is the dangerous one: a cast truncates, and a customer is charged 1998 cents instead of 1999. Send money as an integer in the smallest currency unit plus an explicit currency, &lt;code&gt;{"amount": 1999, "currency": "USD"}&lt;/code&gt;, or as a decimal &lt;em&gt;string&lt;/em&gt; (&lt;code&gt;"19.99"&lt;/code&gt;) that the client parses with a decimal library. Be careful with currencies that don't have two minor digits; this is why the currency should travel with the amount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates without a time zone.&lt;/strong&gt; &lt;code&gt;"2026-11-09 22:03"&lt;/code&gt; means nothing without an offset. Use RFC 3339 / ISO 8601 timestamps with an explicit offset or &lt;code&gt;Z&lt;/code&gt;, and store UTC. The same instant in both forms, from Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-11-09T22:03:00+05:30      (what the user saw in Colombo)
2026-11-09T16:33:00.000Z       (the same instant in UTC)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mixed naming.&lt;/strong&gt; &lt;code&gt;userId&lt;/code&gt; in one response, &lt;code&gt;user_id&lt;/code&gt; in another, &lt;code&gt;UserID&lt;/code&gt; in a third. Pick one convention (snake_case or camelCase), and apply it to field names, query parameters and error members alike. Same for booleans (&lt;code&gt;is_active&lt;/code&gt; everywhere, not &lt;code&gt;active&lt;/code&gt; here and &lt;code&gt;enabled&lt;/code&gt; there), plural collection names, and ID types. A lint step in CI over your OpenAPI file will catch most drift, and having that file at all is the other half of this mistake: an API without a machine-readable description gets documented by whoever last read the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev-talk.com/post/postgresql-vs-mysql-performance-comparison" rel="noopener noreferrer"&gt;PostgreSQL vs MySQL: Performance Comparison&lt;/a&gt; (for choosing the store behind your keyset pagination and idempotency table)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/common-api-design-mistakes" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apidesign</category>
      <category>restapi</category>
      <category>httpstatuscodes</category>
      <category>pagination</category>
    </item>
    <item>
      <title>Git Merge vs Rebase: Practical Examples and When to Use Each</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:13:58 +0000</pubDate>
      <link>https://dev.to/devtalk94/git-merge-vs-rebase-practical-examples-and-when-to-use-each-339</link>
      <guid>https://dev.to/devtalk94/git-merge-vs-rebase-practical-examples-and-when-to-use-each-339</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 9 November 2026. See the&lt;/em&gt;&lt;em&gt;update log&lt;/em&gt;&lt;em&gt;at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You branched off &lt;code&gt;main&lt;/code&gt;, did some work, and now &lt;code&gt;main&lt;/code&gt; has moved on. Git offers two ways to bring the branches back together, &lt;code&gt;git merge&lt;/code&gt; and &lt;code&gt;git rebase&lt;/code&gt;, and both end with the same files on disk. What differs is the history you leave behind, how many conflicts you resolve, and what happens to your teammates if the branch is shared. Pick wrongly and you get either a tangle of merge commits or a force-push that wrecks someone's afternoon.&lt;/p&gt;

&lt;p&gt;This article runs both on the same repository, side by side, with the real commands and output, then covers the situations where the choice matters: conflicts, shared branches, squash merges, pulling, and undoing a mistake. All of it was run on Git 2.43.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Merge&lt;/th&gt;
&lt;th&gt;Rebase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it does&lt;/td&gt;
&lt;td&gt;Adds one new commit with two parents that joins the histories&lt;/td&gt;
&lt;td&gt;Replays your commits one by one on top of the new base, creating &lt;em&gt;new&lt;/em&gt; commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History afterwards&lt;/td&gt;
&lt;td&gt;Branches and joins stay visible&lt;/td&gt;
&lt;td&gt;One straight line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your existing commits&lt;/td&gt;
&lt;td&gt;Untouched, same SHAs&lt;/td&gt;
&lt;td&gt;Replaced, new SHAs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conflicts&lt;/td&gt;
&lt;td&gt;Resolved once, in the merge commit&lt;/td&gt;
&lt;td&gt;Can appear once per replayed commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe on a branch others use&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, unless everyone agrees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reset&lt;/code&gt; before pushing; &lt;code&gt;revert -m 1&lt;/code&gt; after&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reset --hard ORIG_HEAD&lt;/code&gt; or the reflog&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My rule of thumb, as a practical recommendation rather than a law: rebase your own unpublished work to keep it tidy, merge anything that other people already have. The sections below show why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;A small repository. &lt;code&gt;main&lt;/code&gt; has two commits. &lt;code&gt;feature/search&lt;/code&gt; was branched from the second one and has two commits of its own. Meanwhile someone added a third commit to &lt;code&gt;main&lt;/code&gt;, so the histories have diverged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log --oneline --graph --decorate --all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* cb7fefb (HEAD -&amp;gt; main) Update README
| * ad97d89 (feature/search) Add search filters
| * 1625bc4 Add search index
|/
* 61382cb Add app skeleton
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Merge: keep the history exactly as it happened
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch main
git merge feature/search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merge made by the 'ort' strategy.
 search.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 search.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*   a3e6b0f (HEAD -&amp;gt; main) Merge branch 'feature/search'
|\
| * ad97d89 (feature/search) Add search filters
| * 1625bc4 Add search index
* | cb7fefb Update README
|/
* 61382cb Add app skeleton
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing was rewritten. &lt;code&gt;ad97d89&lt;/code&gt; and &lt;code&gt;1625bc4&lt;/code&gt; kept their SHAs, and a new merge commit &lt;code&gt;a3e6b0f&lt;/code&gt; records that the two lines of work joined. The graph is honest, but it gets busy on a team that merges many branches a day. One tool helps: &lt;code&gt;--first-parent&lt;/code&gt; follows only the main line and hides the commits inside each merged branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log --oneline --first-parent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a3e6b0f Merge branch 'feature/search'
cb7fefb Update README
61382cb Add app skeleton
db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a readable "one line per feature" view of &lt;code&gt;main&lt;/code&gt; without rewriting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebase: replay your commits on top of main
&lt;/h2&gt;

&lt;p&gt;Same starting point, different command. Run it on the &lt;em&gt;feature&lt;/em&gt; branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch feature/search
git rebase main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Successfully rebased and updated refs/heads/feature/search.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(While it works, Git also prints a progress line such as &lt;code&gt;Rebasing (2/2)&lt;/code&gt;.) Compare the two commits before and after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:                  after:
ad97d89 Add search filters       cb365e1 Add search filters
1625bc4 Add search index         9a325d7 Add search index
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* cb365e1 (HEAD -&amp;gt; feature/search) Add search filters
* 9a325d7 Add search index
* cb7fefb (main) Update README
* 61382cb Add app skeleton
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit messages and changes are the same, but the SHAs are different. A commit's SHA includes its parent, and the parent changed, so Git created new commits and moved the branch label to them. The old commits still exist (more on that in the undo section). The history is now a single line, and merging into &lt;code&gt;main&lt;/code&gt; becomes a fast-forward with no merge commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch main
git merge feature/search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Updating cb7fefb..cb365e1
Fast-forward
 search.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 search.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want Git to refuse anything that is not a clean fast-forward, add &lt;code&gt;--ff-only&lt;/code&gt;. On the diverged branches from the start of this article it stops and tells you what to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git merge --ff-only feature/search
hint: Diverging branches can't be fast-forwarded, you need to either:
hint:
hint:   git merge --no-ff
hint:
hint: or:
hint:
hint:   git rebase
hint:
hint: Disable this message with "git config advice.diverging false"
fatal: Not possible to fast-forward, aborting.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fast-forward vs --no-ff, when nothing has diverged
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;main&lt;/code&gt; has not moved, a merge does not need a merge commit. Git just slides the branch pointer forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git merge feature/login
Updating 61382cb..e6c3f9a
Fast-forward
 login.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 login.txt

* e6c3f9a (HEAD -&amp;gt; main, feature/login) Add login validation
* 0745ccd Add login form
* 61382cb Add app skeleton
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is linear, and afterwards nobody can tell a branch ever existed. If you want to keep a visible record of the feature as a unit, force a merge commit with &lt;code&gt;--no-ff&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;$ git merge --no-ff -m "Merge feature/login" feature/login
Merge made by the 'ort' strategy.

*   767bc44 (HEAD -&amp;gt; main) Merge feature/login
|\
| * e6c3f9a (feature/login) Add login validation
| * 0745ccd Add login form
|/
* 61382cb Add app skeleton
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keeping a feature branch up to date
&lt;/h2&gt;

&lt;p&gt;The most common daily use: &lt;code&gt;main&lt;/code&gt; moved, and you want its changes in your branch. Merging &lt;code&gt;main&lt;/code&gt; into the feature branch works, but adds a merge commit every time you do it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git merge main
Merge made by the 'ort' strategy.

*   eeec354 (HEAD -&amp;gt; feature/search) Merge branch 'main' into feature/search
|\
| * cb7fefb (main) Update README
* | ad97d89 Add search filters
* | 1625bc4 Add search index
|/
* 61382cb Add app skeleton
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rebasing onto &lt;code&gt;main&lt;/code&gt; instead (as in the earlier section) keeps the branch a clean series of commits on top of the latest &lt;code&gt;main&lt;/code&gt;, which makes the pull request easier to review. The cost is that you must not do it once other people have based work on your branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conflicts: where the two really differ
&lt;/h2&gt;

&lt;p&gt;A merge brings both branches together in one step, so you resolve each conflicting area once. A rebase replays your commits one at a time, so if several of your commits touch the same lines, you may resolve them repeatedly. I set up a conflict deliberately: &lt;code&gt;main&lt;/code&gt; changed &lt;code&gt;timeout=30&lt;/code&gt; to &lt;code&gt;timeout=60&lt;/code&gt;, while the feature branch has two commits, the first setting it to 45 and the second setting 50 and adding a &lt;code&gt;retries&lt;/code&gt; line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* e62c282 (HEAD -&amp;gt; main) Raise timeout to 60
| * 91af4b7 (feature/timeout) Tune timeout and add retries
| * cba3fc7 Tune timeout for slow API
|/
* 1b8f2a7 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Merge: one conflict
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git merge feature/timeout
Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
Automatic merge failed; fix conflicts and then commit the result.

$ cat config.txt
&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt; HEAD
timeout=60
=======
timeout=50
retries=3
&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt; feature/timeout

$ git status --short
UU config.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I resolved it to &lt;code&gt;timeout=60&lt;/code&gt; plus &lt;code&gt;retries=3&lt;/code&gt;, committed, and was done: one resolution, and the graph shows the join.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*   82da93a (HEAD -&amp;gt; main) Merge branch 'feature/timeout'
|\
| * 91af4b7 (feature/timeout) Tune timeout and add retries
| * cba3fc7 Tune timeout for slow API
* | e62c282 Raise timeout to 60
|/
* 1b8f2a7 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rebase: a conflict for each commit that collides
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git rebase main
Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
error: could not apply cba3fc7... Tune timeout for slow API
Could not apply cba3fc7... Tune timeout for slow API

$ cat config.txt
&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt; HEAD
timeout=60
=======
timeout=45
&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt; cba3fc7 (Tune timeout for slow API)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I trimmed Git's &lt;code&gt;hint:&lt;/code&gt; lines throughout.) Here &lt;code&gt;HEAD&lt;/code&gt; is the new base, &lt;code&gt;main&lt;/code&gt;, and the other side is the single commit being replayed. I resolved it to &lt;code&gt;timeout=60&lt;/code&gt;, ran &lt;code&gt;git add config.txt&lt;/code&gt; and &lt;code&gt;git rebase --continue&lt;/code&gt;, and Git moved on to the second commit, which collided as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git rebase --continue
Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
error: could not apply 91af4b7... Tune timeout and add retries
Could not apply 91af4b7... Tune timeout and add retries

$ cat config.txt
&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt; HEAD
timeout=60
=======
timeout=50
retries=3
&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt; 91af4b7 (Tune timeout and add retries)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second resolution: &lt;code&gt;timeout=60&lt;/code&gt; plus &lt;code&gt;retries=3&lt;/code&gt;, then &lt;code&gt;git rebase --continue&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;[detached HEAD 8fc3ae3] Tune timeout and add retries
 1 file changed, 1 insertion(+)
Successfully rebased and updated refs/heads/feature/timeout.

* 8fc3ae3 (HEAD -&amp;gt; feature/timeout) Tune timeout and add retries
* e62c282 (main) Raise timeout to 60
* 1b8f2a7 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the two conflicts: the merge asked once, the rebase asked twice. Second, the branch ended with &lt;em&gt;one&lt;/em&gt; commit, not two. My first resolution (&lt;code&gt;timeout=60&lt;/code&gt;) matched what &lt;code&gt;main&lt;/code&gt; already had, so the first commit became empty and Git dropped it without a message. That is correct behavior, but it surprises people who expect every commit to survive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cutting down repeated conflicts: squash first
&lt;/h3&gt;

&lt;p&gt;If your branch has several small commits that keep colliding, squash them into one before rebasing, so you resolve once. This does throw away the individual commit history, which is the trade-off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reset --soft &lt;span class="nv"&gt;$(&lt;/span&gt;git merge-base main feature/timeout&lt;span class="o"&gt;)&lt;/span&gt;
git commit -m &lt;span class="s2"&gt;"Tune timeout and add retries"&lt;/span&gt;
git rebase main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* d3d35ff (HEAD -&amp;gt; feature/timeout) Tune timeout and add retries
| * e62c282 (main) Raise timeout to 60
|/
* 1b8f2a7 Initial commit
Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
error: could not apply d3d35ff... Tune timeout and add retries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One commit, one conflict. For picking which commits to combine more precisely, use &lt;code&gt;git rebase -i&lt;/code&gt; (covered in the &lt;a href="https://dev-talk.com/blog/git-rebase-rewriting-history-safely" rel="noopener noreferrer"&gt;rebase part of this Git series&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Bailing out of either one
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git merge --abort
git rebase --abort
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both put the branch back as it was before you started. After &lt;code&gt;git rebase --abort&lt;/code&gt; in my test the status was &lt;code&gt;nothing to commit, working tree clean&lt;/code&gt; and the graph showed the original &lt;code&gt;91af4b7&lt;/code&gt; and &lt;code&gt;cba3fc7&lt;/code&gt; commits again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebase refuses to run on a dirty working tree
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git rebase main
error: cannot rebase: You have unstaged changes.
error: Please commit or stash them.

$ git rebase --autostash main
Created autostash: 44ed3df
Applied autostash.
Successfully rebased and updated refs/heads/feature/search.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--autostash&lt;/code&gt; stashes your uncommitted changes, rebases, and reapplies them. If the reapply conflicts, Git keeps the stash for you. For the manual version, see the &lt;a href="https://dev-talk.com/blog/git-stash-cherry-pick-worktrees" rel="noopener noreferrer"&gt;stash part of this series&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The golden rule: don't rewrite commits other people have
&lt;/h2&gt;

&lt;p&gt;Rebase creates new commits to replace old ones. On your own local branch that is harmless. On a branch that a teammate has already pulled, it leaves their copy of the old commits stranded. I simulated that with a bare remote and two clones. Alice and Bob both have &lt;code&gt;feature/search&lt;/code&gt;. Alice rebases it onto an updated &lt;code&gt;main&lt;/code&gt; and tries to push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git push
 ! [rejected]        feature/search -&amp;gt; feature/search (non-fast-forward)
error: failed to push some refs to '…'
hint: Updates were rejected because the tip of your current branch is behind

$ git push --force-with-lease
 + 871afc9...731f5a1 feature/search -&amp;gt; feature/search (forced update)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I replaced the repository path with &lt;code&gt;…&lt;/code&gt;.) The plain push is rejected because the rewritten branch is no longer a descendant of what is on the remote. &lt;code&gt;--force-with-lease&lt;/code&gt; overwrites it. Bob still has the old commits and adds one of his own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* 6db2161 (HEAD -&amp;gt; feature/search) Add search sorting
* 871afc9 (origin/feature/search) Add search filters
* 7ed4e8b Add search index
* db814b5 (origin/main, origin/HEAD, main) Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Bob runs a plain merge-style pull, and gets a conflict in a file he never touched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git pull --no-rebase
Auto-merging search.txt
CONFLICT (add/add): Merge conflict in search.txt
Automatic merge failed; fix conflicts and then commit the result.

$ git status --short
M  README.md
AA search.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;AA&lt;/code&gt; means both sides added &lt;code&gt;search.txt&lt;/code&gt;: Alice's rewritten commits created it, and so did Bob's old ones, as unrelated commits to Git. After &lt;code&gt;git merge --abort&lt;/code&gt;, Bob can recover with &lt;code&gt;git pull --rebase&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;$ git pull --rebase
Successfully rebased and updated refs/heads/feature/search.

* 5474049 (HEAD -&amp;gt; feature/search) Add search sorting
* 731f5a1 (origin/feature/search) Add search filters
* 769bcce Add search index
* bf92bdd (origin/main, origin/HEAD) Update README
* db814b5 (main) Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here Git recognized that Bob's two old commits were already upstream in rewritten form and dropped them, keeping only &lt;code&gt;Add search sorting&lt;/code&gt;. That worked because Alice's rebase did not change the contents of her commits. If she had also edited or conflict-resolved them, the recovery would be harder. Do not rely on it as a plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Always --force-with-lease, never --force
&lt;/h3&gt;

&lt;p&gt;In a second run, Alice rebased locally, then Bob pushed a new commit to the same branch before Alice pushed. Alice had not fetched his commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git push --force-with-lease
 ! [rejected]        feature/search -&amp;gt; feature/search (stale info)

$ git push --force
 + 811a4ba...3265a02 feature/search -&amp;gt; feature/search (forced update)

$ git log --oneline feature/search      # on the remote, afterwards
3265a02 Add search index
36bce11 Update README
d8f1998 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lease refused, because the remote had moved since Alice last looked ("stale info"). The plain force overwrote it, and Bob's commit &lt;code&gt;Add search sorting&lt;/code&gt; is gone from the remote. The lease costs nothing, and it is the difference between an annoying rejection and lost work. The &lt;a href="https://dev-talk.com/blog/git-rebase-rewriting-history-safely" rel="noopener noreferrer"&gt;rebase part of the series&lt;/a&gt; covers pushing after a rebase in more depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Squash merge: one commit per branch
&lt;/h2&gt;

&lt;p&gt;Many teams squash a branch into a single commit when merging a pull request. Locally that is &lt;code&gt;git merge --squash&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;$ git merge --squash feature/search
Automatic merge went well; stopped before committing as requested
Squash commit -- not updating HEAD

$ git commit -m "Add search (squashed)"

* ad97d89 (feature/search) Add search filters
* 1625bc4 Add search index
| * 902732e (HEAD -&amp;gt; main) Add search (squashed)
| * cb7fefb Update README
|/
* 61382cb Add app skeleton
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result on &lt;code&gt;main&lt;/code&gt; is one new commit, and the feature branch is not connected to it in the graph. That has a practical consequence, because Git no longer knows the branch was merged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git branch --merged main
* main

$ git branch -d feature/search
error: the branch 'feature/search' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature/search'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only delete such a branch with &lt;code&gt;-D&lt;/code&gt; after checking that the squash commit contains everything. Also avoid continuing work on a branch after it has been squash-merged, since its old commits will conflict with the squashed copy. The GitHub documentation says the same: "Squash merging works best for short-lived branches."&lt;/p&gt;

&lt;p&gt;GitHub's pull request button offers all three strategies from this article, as documented: create a merge commit (a &lt;code&gt;--no-ff&lt;/code&gt; merge that keeps all the branch's commits), squash and merge, and rebase and merge. For rebase and merge, GitHub's page states that it "always updates the committer information and creates new commit SHAs", so commits on &lt;code&gt;main&lt;/code&gt; will not match the SHAs on your local branch. I did not test GitHub itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebasing a branch that contains merges
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;git rebase&lt;/code&gt; flattens merge commits. Here &lt;code&gt;feature/cart&lt;/code&gt; contains a merge of a smaller branch (&lt;code&gt;feature/cart-coupons&lt;/code&gt;), and &lt;code&gt;main&lt;/code&gt; has moved on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*   4892d20 (HEAD -&amp;gt; feature/cart) Merge coupons into cart
|\
| * b575b69 (feature/cart-coupons) Add coupon codes
* | c934647 Add cart total
|/
* 31aa099 Add cart list
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git rebase main
Successfully rebased and updated refs/heads/feature/cart.
* ff2d7ef (HEAD -&amp;gt; feature/cart) Add coupon codes
* 6a3df45 Add cart total
* 5b818cf Add cart list
* c5906fe (main) Update README
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merge commit disappeared and the structure of the work was flattened. If you want to keep it, use &lt;code&gt;--rebase-merges&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;$ git rebase --rebase-merges main
Successfully rebased and updated refs/heads/feature/cart.

*   8a9845e (HEAD -&amp;gt; feature/cart) Merge coupons into cart
|\
| * 1dd0e01 Add coupon codes
* | 6a3df45 Add cart total
|/
* 5b818cf Add cart list
* c5906fe (main) Update README
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;feature/cart-coupons&lt;/code&gt; itself still points at its old commit in both cases; a rebase moves only the branch you are on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Undoing each one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A merge you have not pushed.&lt;/strong&gt; Git saves the previous tip as &lt;code&gt;ORIG_HEAD&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;$ git reset --hard ORIG_HEAD
HEAD is now at cb7fefb Update README
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A rebase you have not pushed.&lt;/strong&gt; Same trick, and the old commits come back with their old SHAs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:        ad97d89 Add search filters / 1625bc4 Add search index
after rebase: 6976b4e Add search filters / f18a57b Add search index

$ git reset --hard ORIG_HEAD
HEAD is now at ad97d89 Add search filters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The rebased SHAs differ from the earlier rebase example because this was a separate test run; the point is that they change.) &lt;code&gt;ORIG_HEAD&lt;/code&gt; is overwritten by the next merge, reset or rebase, so if you have done anything since, use &lt;code&gt;git reflog&lt;/code&gt;. The &lt;a href="https://dev-talk.com/blog/git-undo-mistakes-reset-revert-reflog" rel="noopener noreferrer"&gt;undo part of the series&lt;/a&gt; covers that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A merge that is already pushed.&lt;/strong&gt; You cannot rewrite shared history, so you add a commit that reverses it. A merge commit has two parents, so you must tell Git which side to keep with &lt;code&gt;-m 1&lt;/code&gt; (the first parent, normally &lt;code&gt;main&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;$ git revert -m 1 HEAD
[main 1b63300] Revert "Merge branch 'feature/search'"
 Date: Thu Jan 1 00:01:00 2026 +0000
 1 file changed, 2 deletions(-)
$ ls
README.md
app.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The feature's changes are gone from the working tree. Now the trap: when the fixed branch is ready and you merge it again, Git thinks it is already merged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git merge feature/search
Already up to date.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original merge still counts as having included those commits. To get the changes back, revert the revert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git revert --no-edit 1b63300
[main f224762] Reapply "Merge branch 'feature/search'"
 Date: Thu Jan 1 00:02:00 2026 +0000
$ ls
README.md
app.txt
search.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or merge a new branch that includes your fixes as new commits on top of the old ones. Either way, know about this behavior before the day you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What git pull does: pick a default
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git pull&lt;/code&gt; is a fetch followed by a merge or a rebase, and Git 2.43 will not pick for you when the branches have diverged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git pull
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
fatal: Need to specify how to reconcile divergent branches.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git pull --ff-only
fatal: Not possible to fast-forward, aborting.
$ git pull --rebase
Successfully rebased and updated refs/heads/main.
* 47f354b (HEAD -&amp;gt; main) Bob: add notes
* ddd4bcf (origin/main, origin/HEAD) Alice: add app
* db814b5 Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set one default and stop thinking about it. &lt;code&gt;pull.rebase true&lt;/code&gt; keeps your local, unpushed commits on top of the remote ones, which is safe because those are the only commits being rewritten. &lt;code&gt;pull.ff only&lt;/code&gt; makes a diverged pull stop and ask, which is the most cautious. Use &lt;code&gt;git config --global&lt;/code&gt; to apply either to every repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your own unpushed branch is behind &lt;code&gt;main&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rebase&lt;/td&gt;
&lt;td&gt;Clean history, nobody affected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A branch you pushed, only you use it&lt;/td&gt;
&lt;td&gt;Rebase, then &lt;code&gt;--force-with-lease&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Safe if you are the only one; the lease checks it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A branch shared with teammates&lt;/td&gt;
&lt;td&gt;Merge&lt;/td&gt;
&lt;td&gt;Rewriting strands their copies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;main&lt;/code&gt;, &lt;code&gt;develop&lt;/code&gt;, release branches&lt;/td&gt;
&lt;td&gt;Merge, never rebase&lt;/td&gt;
&lt;td&gt;Everyone builds on them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finished feature into &lt;code&gt;main&lt;/code&gt;, you want context&lt;/td&gt;
&lt;td&gt;Merge commit (&lt;code&gt;--no-ff&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;One visible unit per feature, easy &lt;code&gt;revert -m 1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finished feature, many noisy commits&lt;/td&gt;
&lt;td&gt;Squash&lt;/td&gt;
&lt;td&gt;One tidy commit per pull request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finished feature, you want a straight line with every commit&lt;/td&gt;
&lt;td&gt;Rebase and fast-forward&lt;/td&gt;
&lt;td&gt;Linear history, each commit stays&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need to undo a pushed integration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;revert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never rewrite published history&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A workflow that works for many teams, again as a recommendation: rebase locally while you work, open the pull request, let the team's chosen merge strategy (merge commit or squash) land it on &lt;code&gt;main&lt;/code&gt;, and never rebase a branch after someone else has pulled it. What matters more than the choice is that everyone on the team uses the same one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rebasing a branch other people have pulled.&lt;/strong&gt; It strands their commits and creates confusing conflicts like the &lt;code&gt;add/add&lt;/code&gt; above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using&lt;/strong&gt;&lt;code&gt;git push --force&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; Use &lt;code&gt;--force-with-lease&lt;/code&gt;. In my test the plain force deleted a teammate's commit from the remote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebasing&lt;/strong&gt;&lt;code&gt;main&lt;/code&gt;&lt;strong&gt;onto a feature branch.&lt;/strong&gt; The direction matters: rebase the &lt;em&gt;feature&lt;/em&gt; onto &lt;code&gt;main&lt;/code&gt;, never the other way around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Panicking when commits seem to vanish after a rebase.&lt;/strong&gt; They are still there; use &lt;code&gt;ORIG_HEAD&lt;/code&gt; or &lt;code&gt;git reflog&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolving the same conflict repeatedly.&lt;/strong&gt; Squash first, or consider merging instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting a squash-merged branch with&lt;/strong&gt;&lt;code&gt;-D&lt;/code&gt;&lt;strong&gt;without checking.&lt;/strong&gt; Git cannot tell it was merged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merging again after&lt;/strong&gt;&lt;code&gt;revert -m 1&lt;/code&gt;&lt;strong&gt;and getting "Already up to date".&lt;/strong&gt; Revert the revert instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring the&lt;/strong&gt;&lt;code&gt;pull&lt;/code&gt;&lt;strong&gt;default.&lt;/strong&gt; Set &lt;code&gt;pull.rebase&lt;/code&gt; or &lt;code&gt;pull.ff&lt;/code&gt; once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alternatives to both
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git cherry-pick&lt;/code&gt; to copy specific commits without merging the whole branch (see the &lt;a href="https://dev-talk.com/blog/git-stash-cherry-pick-worktrees" rel="noopener noreferrer"&gt;stash and cherry-pick part&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git rebase -i&lt;/code&gt; to reorder, edit and squash your commits before you share them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git merge --squash&lt;/code&gt; when you want the changes but a single commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles and tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/git-branching-merging-resolving-conflicts" rel="noopener noreferrer"&gt;Git merge conflicts: branching, merging and how to resolve them&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/git-rebase-rewriting-history-safely" rel="noopener noreferrer"&gt;Git rebase explained: rewrite history without breaking it&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/git-undo-mistakes-reset-revert-reflog" rel="noopener noreferrer"&gt;Git undo commit: reset, revert and reflog recovery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/git-daily-workflow-staging-commits-gitignore" rel="noopener noreferrer"&gt;Git daily workflow: staging, commits and .gitignore&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools: &lt;code&gt;git log --oneline --graph --decorate --all&lt;/code&gt; shows exactly what these examples show, and &lt;code&gt;git reflog&lt;/code&gt; is the safety net. A graphical client can draw the same graph; I did not use one here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/pull-requests/reference/pull-request-merges" rel="noopener noreferrer"&gt;GitHub Docs: Pull request merges&lt;/a&gt; (merge commit, squash and merge, rebase and merge)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/git-merge-vs-rebase-practical-examples" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>gitmerge</category>
      <category>gitrebase</category>
      <category>versioncontrol</category>
    </item>
    <item>
      <title>Secure a Laravel App Before Production: Tested Checklist</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Fri, 13 Nov 2026 03:38:46 +0000</pubDate>
      <link>https://dev.to/devtalk94/secure-a-laravel-app-before-production-tested-checklist-16a0</link>
      <guid>https://dev.to/devtalk94/secure-a-laravel-app-before-production-tested-checklist-16a0</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 8 November 2026. See the&lt;/em&gt;&lt;em&gt;update log&lt;/em&gt;&lt;em&gt;at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Laravel gives you safe defaults for a lot of things: Blade escapes output, the query builder uses bound parameters, forms need a CSRF token, and session IDs change on login. But a handful of protections only exist if you set them up, and the dangerous ones fail silently. Nothing breaks when debug mode is on, when the web server points at the wrong folder, or when a user can open someone else's record by changing a number in the URL. The app just works, until someone finds it.&lt;/p&gt;

&lt;p&gt;This article is a pre-launch checklist where every item was reproduced against a real Laravel 13.40.0 app over HTTP, so you see the actual requests, responses and numbers, including the defaults that are not safe. For each item you get the problem, the evidence, the fix, and how to check it afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel handles for you, and what it does not
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Out of the box&lt;/th&gt;
&lt;th&gt;Your job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;XSS in Blade&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{ }}&lt;/code&gt; escapes&lt;/td&gt;
&lt;td&gt;Never use &lt;code&gt;{!! !!}&lt;/code&gt; on user input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL injection&lt;/td&gt;
&lt;td&gt;Bound parameters in the query builder and Eloquent&lt;/td&gt;
&lt;td&gt;Never interpolate variables into raw SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSRF&lt;/td&gt;
&lt;td&gt;Web routes reject a missing token with 419&lt;/td&gt;
&lt;td&gt;Be careful with exclusions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session fixation&lt;/td&gt;
&lt;td&gt;Session ID regenerated on login&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debug output&lt;/td&gt;
&lt;td&gt;Enabled in the default &lt;code&gt;.env&lt;/code&gt; file&lt;/td&gt;
&lt;td&gt;Turn it off in production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mass assignment&lt;/td&gt;
&lt;td&gt;The skeleton's &lt;code&gt;User&lt;/code&gt; model whitelists fields&lt;/td&gt;
&lt;td&gt;Your own models and request handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;Nothing automatic&lt;/td&gt;
&lt;td&gt;Every route that loads a record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brute-force protection&lt;/td&gt;
&lt;td&gt;Throttle middleware is available but not applied to your routes&lt;/td&gt;
&lt;td&gt;Add it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security headers&lt;/td&gt;
&lt;td&gt;None sent&lt;/td&gt;
&lt;td&gt;Add a middleware or set them at the web server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy and host trust&lt;/td&gt;
&lt;td&gt;Forwarded headers ignored by default&lt;/td&gt;
&lt;td&gt;Configure it correctly for your setup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Turn off debug mode and set the environment
&lt;/h2&gt;

&lt;p&gt;A fresh Laravel app's &lt;code&gt;.env&lt;/code&gt; has &lt;code&gt;APP_ENV=local&lt;/code&gt; and &lt;code&gt;APP_DEBUG=true&lt;/code&gt;. In production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;APP_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production
&lt;span class="nv"&gt;APP_DEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made a route that throws an exception and requested it both ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APP_DEBUG=true:   status 500, 842,814 bytes, 110 references to absolute server paths,
                  stack trace with source code
APP_DEBUG=false:  status 500, 6,592 bytes, a generic "Server Error" page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The debug page told an attacker my directory layout, the framework internals and application code around the failure. I did not find &lt;code&gt;APP_KEY&lt;/code&gt; or database passwords in that page's text in this test, but file paths and code are already more than anyone should get from an error. Debug mode on staging servers that are reachable from the internet counts too. More on this in &lt;a href="https://dev-talk.com/laravel-production-mistakes" rel="noopener noreferrer"&gt;5 Laravel production mistakes that pass every local test&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Point the web server at public/, never the project root
&lt;/h2&gt;

&lt;p&gt;Only the &lt;code&gt;public&lt;/code&gt; folder should be reachable from the web. I served the project root instead of &lt;code&gt;public&lt;/code&gt; and requested &lt;code&gt;/.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;document root = project root:  GET /.env  -&amp;gt;  status 200, file contents returned (APP_KEY included)
document root = public/:       GET /.env  -&amp;gt;  status 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A web server pointed at the wrong folder serves your secrets to the world. In Nginx, set the root to &lt;code&gt;public&lt;/code&gt; and refuse dotfiles as a second line of defense. This follows the structure of the standard Laravel Nginx config; I did not run Nginx for this article:&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="n"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;www&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;try_files&lt;/span&gt; $&lt;span class="n"&gt;uri&lt;/span&gt; $&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;$&lt;span class="n"&gt;query_string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?!&lt;/span&gt;&lt;span class="n"&gt;well&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="n"&gt;all&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;Check it from outside after every infrastructure change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s2"&gt;"%{http_code}\n"&lt;/span&gt; https://your-site.example/.env
&lt;span class="c"&gt;# expect 404 or 403, never 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Protect APP_KEY and plan for rotating it
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;APP_KEY&lt;/code&gt; encrypts cookies, sessions (if you encrypt them) and anything you pass through &lt;code&gt;Crypt&lt;/code&gt;. The skeleton's &lt;code&gt;.gitignore&lt;/code&gt; already ignores &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.backup&lt;/code&gt; and &lt;code&gt;.env.production&lt;/code&gt;; keep it that way, and keep keys out of build logs and chat messages. The skeleton's session config sets &lt;code&gt;'serialization' =&amp;gt; 'json'&lt;/code&gt;, and its comment explains that this guards against "gadget chain" attacks if the key leaks.&lt;/p&gt;

&lt;p&gt;If a key does leak, you need to replace it without logging everyone out or breaking stored encrypted data. Laravel reads old keys from &lt;code&gt;APP_PREVIOUS_KEYS&lt;/code&gt; (comma separated) in &lt;code&gt;config/app.php&lt;/code&gt;. I tested the mechanism with the framework's encrypter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new key only:                  DecryptException: The MAC is invalid.
new key + previousKeys([old]): session-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan key:generate --show      &lt;span class="c"&gt;# prints a new key without writing it&lt;/span&gt;
&lt;span class="c"&gt;# then in .env:&lt;/span&gt;
&lt;span class="nv"&gt;APP_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;base64:NEW_KEY...
&lt;span class="nv"&gt;APP_PREVIOUS_KEYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;base64:OLD_KEY...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--show&lt;/code&gt; option is listed in the command's help ("Display a new key instead of modifying files"). Remove old keys once the data encrypted with them has expired or been re-encrypted. For storing the file itself, &lt;code&gt;php artisan env:encrypt&lt;/code&gt; and &lt;code&gt;env:decrypt&lt;/code&gt; exist in 13.40 (I listed them but did not run them), which lets you commit an encrypted environment file and keep the decryption key elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cache config in production, and stop calling env() outside config files
&lt;/h2&gt;

&lt;p&gt;This is partly a performance item and partly a security one, because it changes where your settings really come from. With the config cached, &lt;code&gt;env()&lt;/code&gt; outside &lt;code&gt;config/*.php&lt;/code&gt; no longer reads &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before config:cache:  {"env":"Laravel","config":"Laravel"}
after config:cache:   {"env":null,"config":"Laravel"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code like &lt;code&gt;if (env('APP_DEBUG'))&lt;/code&gt; in a controller then silently gets &lt;code&gt;null&lt;/code&gt;. Read settings with &lt;code&gt;config()&lt;/code&gt;, and put every &lt;code&gt;env()&lt;/code&gt; call in a config file. On deploy:&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 optimize
php artisan about &lt;span class="o"&gt;|&lt;/span&gt; grep -E &lt;span class="s2"&gt;"Environment|Debug Mode|Config|Routes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Environment ..................................................... production
Debug Mode ............................................................. OFF
Config .............................................................. CACHED
Routes .............................................................. CACHED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output is from my test app with production settings. &lt;code&gt;about&lt;/code&gt; is a fast way to confirm what a server is actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Force HTTPS and secure cookies
&lt;/h2&gt;

&lt;p&gt;The default session cookie is &lt;code&gt;HttpOnly&lt;/code&gt; with &lt;code&gt;SameSite=Lax&lt;/code&gt;, but it has no &lt;code&gt;Secure&lt;/code&gt; flag, so a browser will send it over plain HTTP too. This is what the default response sends (values removed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set-Cookie: XSRF-TOKEN=...; path=/; samesite=lax
Set-Cookie: laravel-session=...; path=/; httponly; samesite=lax
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;SESSION_SECURE_COOKIE=true&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set-Cookie: XSRF-TOKEN=...; path=/; secure; samesite=lax
Set-Cookie: laravel-session=...; path=/; secure; httponly; samesite=lax
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;XSRF-TOKEN&lt;/code&gt; cookie is not &lt;code&gt;HttpOnly&lt;/code&gt; by design, so JavaScript can read it for AJAX requests. Redirect HTTP to HTTPS at the load balancer or web server, and send an HSTS header (see item 8). Setting the secure flag before your site is fully on HTTPS locks out your own sessions on HTTP, so do the two together.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Trust proxies precisely, never blindly
&lt;/h2&gt;

&lt;p&gt;Behind a load balancer, the app must read the real client IP and scheme from &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers, but only from your proxy. By default Laravel ignores them, which is safe but means &lt;code&gt;isSecure()&lt;/code&gt; is false and every request appears to come from the proxy's IP. The tempting fix is &lt;code&gt;trustProxies(at: '*')&lt;/code&gt;. I tested all three configurations, sending forged &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; and &lt;code&gt;X-Forwarded-For&lt;/code&gt; headers, then eight wrong logins (each with a different forged IP) against a route limited to five attempts per minute:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Forged headers accepted?&lt;/th&gt;
&lt;th&gt;8 login attempts with different forged IPs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default (none)&lt;/td&gt;
&lt;td&gt;No: &lt;code&gt;isSecure=false, ip=127.0.0.1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;422 422 422 422 422 &lt;strong&gt;429 429 429&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;at: '*'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes: &lt;code&gt;isSecure=true, ip=1.2.3.4&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;422 422 422 422 422 422 422 422 (&lt;strong&gt;throttle bypassed&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;at: '10.0.0.0/8'&lt;/code&gt; (not the sender)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;422 422 422 422 422 &lt;strong&gt;429 429 429&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bootstrap/app.php&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withMiddleware&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="nc"&gt;Middleware&lt;/span&gt; &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;)&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="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;trustProxies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'10.0.0.0/8'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// your load balancer's address range&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 &lt;code&gt;at: '*'&lt;/code&gt; any client could pretend to be any IP, which defeats IP-based throttling and corrupts your logs. Using &lt;code&gt;'*'&lt;/code&gt; is only reasonable when the app can be reached exclusively through the proxy, for example inside a private network where the proxy is the only way in. Otherwise list your proxy's IP or CIDR range. The method also takes an array, which I did not test.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Trust your own hostnames
&lt;/h2&gt;

&lt;p&gt;Laravel builds URLs from the request's &lt;code&gt;Host&lt;/code&gt; header unless told otherwise. A password-reset email that uses &lt;code&gt;url()&lt;/code&gt; or &lt;code&gt;route()&lt;/code&gt; can then link to an attacker's domain if they send a forged &lt;code&gt;Host&lt;/code&gt;. I requested a route that returns &lt;code&gt;url('/reset-password/abc123')&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -H &lt;span class="s2"&gt;"Host: evil.example"&lt;/span&gt; http://127.0.0.1:8300/host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"host":"evil.example","url":"http://evil.example/reset-password/abc123", ...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is &lt;code&gt;trustHosts&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="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;trustHosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'^app\.example\.com$'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APP_ENV=production, Host: evil.example     -&amp;gt; status 400
APP_ENV=production, Host: 127.0.0.1:8300   -&amp;gt; status 200 (an allowed host)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap I hit: the middleware does nothing when &lt;code&gt;APP_ENV=local&lt;/code&gt;. The framework's &lt;code&gt;shouldSpecifyTrustedHosts()&lt;/code&gt; returns false in the local environment and in unit tests, so in my first attempt the forged host still got a 200. Test it with production settings, or you will believe it works when it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Send security headers
&lt;/h2&gt;

&lt;p&gt;Laravel sends none. A small middleware covers the common ones:&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="o"&gt;&amp;lt;?&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;\&lt;span class="nc"&gt;Http&lt;/span&gt;\&lt;span class="nc"&gt;Middleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Closure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Http&lt;/span&gt;\&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony&lt;/span&gt;\&lt;span class="nc"&gt;Component&lt;/span&gt;\&lt;span class="nc"&gt;HttpFoundation&lt;/span&gt;\&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SecurityHeaders&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="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&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="nc"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Content-Type-Options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'nosniff'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Frame-Options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DENY'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Referrer-Policy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'strict-origin-when-cross-origin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Permissions-Policy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'camera=(), microphone=(), geolocation=()'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&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;isSecure&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;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Strict-Transport-Security'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max-age=31536000; includeSubDomains'&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;return&lt;/span&gt; &lt;span class="nv"&gt;$response&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bootstrap/app.php&lt;/span&gt;
&lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;\&lt;span class="nc"&gt;App&lt;/span&gt;\&lt;span class="nc"&gt;Http&lt;/span&gt;\&lt;span class="nc"&gt;Middleware&lt;/span&gt;\&lt;span class="nc"&gt;SecurityHeaders&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HSTS only appears when the request is seen as secure, so it depends on item 6: behind an HTTPS-terminating proxy that you have not trusted, &lt;code&gt;isSecure()&lt;/code&gt; is false and the header is never sent (I confirmed both outcomes in the table above, with HSTS present only under &lt;code&gt;at: '*'&lt;/code&gt;). Start with a short &lt;code&gt;max-age&lt;/code&gt; while you test, because browsers remember HSTS. A &lt;code&gt;Content-Security-Policy&lt;/code&gt; is the strongest header against XSS, but it needs tuning to your scripts and styles, so I left it out of this generic example.&lt;/p&gt;

&lt;p&gt;The test server also sent &lt;code&gt;X-Powered-By: PHP/8.3.6&lt;/code&gt;. Set &lt;code&gt;expose_php = Off&lt;/code&gt; in &lt;code&gt;php.ini&lt;/code&gt; to stop advertising the version (standard PHP setting, not tested here).&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Keep CSRF protection on
&lt;/h2&gt;

&lt;p&gt;In Laravel 13.40 the web middleware group includes &lt;code&gt;PreventRequestForgery&lt;/code&gt;, and the older &lt;code&gt;VerifyCsrfToken&lt;/code&gt; class is a deprecated subclass of it. A POST without a token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s2"&gt;"%{http_code}\n"&lt;/span&gt; -X POST http://127.0.0.1:8300/ping
&lt;span class="c"&gt;# 419&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you must exclude a route, such as a payment provider's webhook, do it narrowly, and verify the sender some other way (a signature check), because an excluded route has no CSRF protection at all:&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;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;preventRequestForgery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;except&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'webhooks/stripe'&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 method also has &lt;code&gt;originOnly&lt;/code&gt; and &lt;code&gt;allowSameSite&lt;/code&gt; options in the 13.40 source; read their docs before changing them. If real users hit 419 errors, see &lt;a href="https://dev-talk.com/laravel-419-page-expired" rel="noopener noreferrer"&gt;Laravel 419 Page Expired: causes and fixes&lt;/a&gt; rather than turning the protection off.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Harden login: hashing, throttling, sessions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hashing.&lt;/strong&gt; The skeleton sets &lt;code&gt;BCRYPT_ROUNDS=12&lt;/code&gt;. Cost doubles with each extra round, so measure on your hardware. On this machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bcrypt rounds=10:   51 ms
bcrypt rounds=12:  198 ms
bcrypt rounds=14:  796 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are single runs; the pattern (about 4 times slower per two rounds) is what matters. Pick the highest cost your login endpoint can afford under load, and use &lt;code&gt;Hash::needsRehash()&lt;/code&gt; at login to upgrade old hashes as users sign in. PHP here also offered &lt;code&gt;argon2i&lt;/code&gt; and &lt;code&gt;argon2id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throttling.&lt;/strong&gt; Login routes need a rate limit. The built-in middleware worked as expected on a route with &lt;code&gt;throttle:5,1&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;attempt 1-5 -&amp;gt; 422  X-RateLimit-Limit: 5  X-RateLimit-Remaining: 4 ... 0
attempt 6   -&amp;gt; 429  Retry-After: 59
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For guests that limit is keyed by IP address, which is why item 6 matters. A more targeted limiter keys on the account as well. This is the standard form from the Laravel documentation, not something I ran:&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;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Cache&lt;/span&gt;\&lt;span class="nc"&gt;RateLimiting&lt;/span&gt;\&lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'login'&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="nc"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strtolower&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;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&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="s1"&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;ip&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;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;// Route::post('/login', ...)-&amp;gt;middleware('throttle:login');&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For API limits and algorithms in depth, see &lt;a href="https://dev-talk.com/api-rate-limiting-strategies" rel="noopener noreferrer"&gt;API rate limiting strategies&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sessions.&lt;/strong&gt; &lt;code&gt;Auth::attempt()&lt;/code&gt; regenerates the session ID, which blocks session fixation. I checked it in the &lt;code&gt;sessions&lt;/code&gt; table: before login the newest row had a null &lt;code&gt;user_id&lt;/code&gt;; after login the ID was different and &lt;code&gt;user_id&lt;/code&gt; was set. In the source this is &lt;code&gt;$this-&amp;gt;session-&amp;gt;regenerate(true)&lt;/code&gt; in &lt;code&gt;SessionGuard::updateSession()&lt;/code&gt;. If you write your own login code, call &lt;code&gt;$request-&amp;gt;session()-&amp;gt;regenerate()&lt;/code&gt; yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Authorize every record access (the IDOR check)
&lt;/h2&gt;

&lt;p&gt;Authentication tells you who the user is. Authorization decides what they may touch. Route model binding fetches any record, for anyone, unless you check. I logged in as &lt;code&gt;alice&lt;/code&gt; and requested &lt;code&gt;bob&lt;/code&gt;'s record on two routes:&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;// No check: any logged-in user can read any user's email&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/users/{user}/email-unsafe'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// With an ownership check&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/users/{user}/email-safe'&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="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allowIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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="nv"&gt;$auth&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&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;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alice reads bob, no check:      bob@example.test
alice reads bob, ownership check: status 403
alice reads herself:             alice@example.test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For real apps, use policies (&lt;code&gt;$this-&amp;gt;authorize('view', $invoice)&lt;/code&gt; or &lt;code&gt;Gate::authorize()&lt;/code&gt;) and write a test per resource that logs in as a second user and expects a 403. This is the bug most likely to survive review, because no error ever appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Block mass assignment
&lt;/h2&gt;

&lt;p&gt;Passing &lt;code&gt;$request-&amp;gt;all()&lt;/code&gt; into &lt;code&gt;create()&lt;/code&gt; lets a client set any column, including ones your form never showed. Laravel 13's skeleton &lt;code&gt;User&lt;/code&gt; model whitelists fields with an attribute:&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;#[Fillable(['name', 'email', 'password'])]&lt;/span&gt;
&lt;span class="c1"&gt;#[Hidden(['password', 'remember_token'])]&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I compared that with an unguarded model on the same table (&lt;code&gt;protected $guarded = [];&lt;/code&gt;), posting &lt;code&gt;is_admin=1&lt;/code&gt; to both:&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;// Vulnerable&lt;/span&gt;
&lt;span class="nv"&gt;$u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LooseUser&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="nv"&gt;$request&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="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;// Safer: validate, and use only validated data on a model that whitelists fields&lt;/span&gt;
&lt;span class="nv"&gt;$data&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;validate&lt;/span&gt;&lt;span class="p"&gt;(&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="s1"&gt;'required|string|max:100'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|email|unique:users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|min:12'&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;span class="nv"&gt;$u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&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="nv"&gt;$data&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unguarded model + $request-&amp;gt;all():       {"is_admin":1}
validated data + whitelisted model:      {"is_admin":0}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat &lt;code&gt;$guarded = []&lt;/code&gt; as a bug unless the data is never from a request. In development, &lt;code&gt;Model::shouldBeStrict()&lt;/code&gt; turns silently discarded attributes into exceptions (see the &lt;a href="https://dev-talk.com/laravel-13-performance-optimization" rel="noopener noreferrer"&gt;performance article&lt;/a&gt; for how it behaves).&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Never interpolate into SQL, never print raw HTML
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SQL.&lt;/strong&gt; Bound parameters are the defense. Here are two raw queries, one with the input pasted into the string and one with a placeholder, given the classic payload &lt;code&gt;' OR '1'='1&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;// Vulnerable&lt;/span&gt;
&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"select id, name from users where name = '{$request-&amp;gt;query('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;// Safe&lt;/span&gt;
&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'select id, name from users where name = ?'&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;query&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="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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;interpolated: [{"id":1,"name":"alice"},{"id":2,"name":"bob"}]   (every row returned)
bound:        []
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rule applies to &lt;code&gt;whereRaw()&lt;/code&gt;, &lt;code&gt;selectRaw()&lt;/code&gt; and &lt;code&gt;orderByRaw()&lt;/code&gt;. Bindings protect values, not column names: if a user chooses the sort column, check it against an allow-list. See &lt;a href="https://dev-talk.com/eloquent-vs-query-builder" rel="noopener noreferrer"&gt;Eloquent vs Query Builder&lt;/a&gt; for when each fits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;XSS.&lt;/strong&gt; I rendered &lt;code&gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;&lt;/code&gt; both ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ $q }}     -&amp;gt; &amp;lt;p&amp;gt;escaped: &amp;amp;lt;script&amp;amp;gt;alert(1)&amp;amp;lt;/script&amp;amp;gt;&amp;lt;/p&amp;gt;
{!! $q !!}   -&amp;gt; &amp;lt;p&amp;gt;raw: &amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;{!! !!}&lt;/code&gt; only for HTML you generated yourself. If users may submit HTML, run it through a sanitizer first (I did not test one) and add a Content-Security-Policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Treat uploads as hostile
&lt;/h2&gt;

&lt;p&gt;Never trust the file extension or the client's content type. I uploaded a real PNG, a PHP script renamed &lt;code&gt;evil.jpg&lt;/code&gt; (sent with &lt;code&gt;Content-Type: image/jpeg&lt;/code&gt;) and a file named &lt;code&gt;evil.php&lt;/code&gt;, against this validation:&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'avatar'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mimes:jpg,jpeg,png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:2048'&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;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$path&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;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'avatar'&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;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'avatars'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;real.png  -&amp;gt; {"stored":"avatars/JNQgygKEpFyrUOQkyojulvOi4gEUyGBJvg1fq290.png"}
evil.jpg  -&amp;gt; "The avatar field must be an image." / "must be a file of type: jpg, jpeg, png."
evil.php  -&amp;gt; the same two errors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel checks the file's real content, so renaming does not pass. Two more details from the result: the stored name is random (&lt;code&gt;store()&lt;/code&gt; generates it, so never use the client's filename), and the file landed in &lt;code&gt;storage/app/private/avatars/&lt;/code&gt;, outside the web root. Keep uploads on a private disk and serve them through a controller or signed URLs unless they must be public. Also set a request size limit at the web server and in PHP (&lt;code&gt;upload_max_filesize&lt;/code&gt;, &lt;code&gt;post_max_size&lt;/code&gt;), which I did not test.&lt;/p&gt;

&lt;h2&gt;
  
  
  15. Audit dependencies and keep patching
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer install --no-dev --optimize-autoloader   &lt;span class="c"&gt;# no dev tools on the server&lt;/span&gt;
composer audit                                      &lt;span class="c"&gt;# check installed packages against known advisories&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;composer audit&lt;/code&gt; command is present in Composer 2.8.12 here. I could not run it, because it fetches advisories from Packagist, which this environment cannot reach. Run it in CI on every build and on a schedule, since new advisories appear for code you have not changed. Keep the framework current within your major version (this article's tests used 13.40.0), and read the release notes of security releases. If you are on Laravel 12, see the &lt;a href="https://dev-talk.com/upgrade-laravel-12-to-13" rel="noopener noreferrer"&gt;12 to 13 upgrade guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure items I did not test
&lt;/h2&gt;

&lt;p&gt;These matter just as much and live outside the Laravel app, so verify them in your own environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The database, Redis and any queue backend should not be reachable from the internet. Bind to private addresses and use a firewall or security group.&lt;/li&gt;
&lt;li&gt;Use separate database users for the app and for migrations, with the least privileges that work.&lt;/li&gt;
&lt;li&gt;Only &lt;code&gt;storage&lt;/code&gt; and &lt;code&gt;bootstrap/cache&lt;/code&gt; need to be writable by the web server user.&lt;/li&gt;
&lt;li&gt;Keep backups, and test restoring one.&lt;/li&gt;
&lt;li&gt;Do not log passwords, tokens or full request bodies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pre-launch checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;How to verify&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;APP_ENV=production&lt;/code&gt;, &lt;code&gt;APP_DEBUG=false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;php artisan about&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Document root is &lt;code&gt;public/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;curl -I https://site/.env&lt;/code&gt; returns 404 or 403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Key not in git, rotation plan exists&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;.gitignore&lt;/code&gt;, note &lt;code&gt;APP_PREVIOUS_KEYS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Config cached, no &lt;code&gt;env()&lt;/code&gt; outside config&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;php artisan optimize&lt;/code&gt;, search for &lt;code&gt;env(&lt;/code&gt; in &lt;code&gt;app/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;HTTPS redirect and secure cookies&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SESSION_SECURE_COOKIE=true&lt;/code&gt;, inspect &lt;code&gt;Set-Cookie&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Trusted proxies set to your proxy only&lt;/td&gt;
&lt;td&gt;Forged &lt;code&gt;X-Forwarded-For&lt;/code&gt; does not change &lt;code&gt;$request-&amp;gt;ip()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Trusted hosts set&lt;/td&gt;
&lt;td&gt;Forged &lt;code&gt;Host&lt;/code&gt; gets a 400 in production mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Security headers present&lt;/td&gt;
&lt;td&gt;`curl -sI &lt;a href="https://site" rel="noopener noreferrer"&gt;https://site&lt;/a&gt; \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;CSRF on, exclusions justified&lt;/td&gt;
&lt;td&gt;POST without a token returns 419&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Login throttled&lt;/td&gt;
&lt;td&gt;Sixth bad attempt returns 429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Policies on every record route&lt;/td&gt;
&lt;td&gt;A second-user test expects 403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;No {% raw %}&lt;code&gt;$guarded = []&lt;/code&gt; with request data&lt;/td&gt;
&lt;td&gt;Search for &lt;code&gt;guarded&lt;/code&gt; and &lt;code&gt;$request-&amp;gt;all()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;No interpolated raw SQL, no raw Blade output&lt;/td&gt;
&lt;td&gt;Search for &lt;code&gt;Raw(&lt;/code&gt;, &lt;code&gt;DB::select("&lt;/code&gt; and &lt;code&gt;{!!&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;Uploads validated and stored privately&lt;/td&gt;
&lt;td&gt;Upload a renamed script and expect a validation error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;composer audit&lt;/code&gt; clean in CI&lt;/td&gt;
&lt;td&gt;CI job output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checking security only in the local environment.&lt;/strong&gt; Trusted hosts does nothing in &lt;code&gt;local&lt;/code&gt;, and debug is on by default there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using&lt;/strong&gt;&lt;code&gt;trustProxies(at: '*')&lt;/code&gt;&lt;strong&gt;because it makes HTTPS detection work.&lt;/strong&gt; It also makes IP addresses forgeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authenticating but not authorizing.&lt;/strong&gt; &lt;code&gt;middleware('auth')&lt;/code&gt; only proves someone is logged in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disabling CSRF globally to fix a 419.&lt;/strong&gt; Find the cause instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validating by file extension.&lt;/strong&gt; Use content-based rules and a private disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relying on a WAF or CDN instead of fixing the app.&lt;/strong&gt; They reduce noise; they do not repair an IDOR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Committing&lt;/strong&gt;&lt;code&gt;.env.production&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; The skeleton ignores it for a reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles and tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/api-rate-limiting-strategies" rel="noopener noreferrer"&gt;API rate limiting strategies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/laravel-cors-error-react-vue-frontend" rel="noopener noreferrer"&gt;Laravel CORS errors with a React or Vue frontend&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/mysql-too-many-connections" rel="noopener noreferrer"&gt;MySQL "Too many connections"&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools: &lt;code&gt;composer audit&lt;/code&gt;, &lt;code&gt;php artisan about&lt;/code&gt;, and &lt;code&gt;curl -I&lt;/code&gt; for checking headers and exposed files from outside. A static analyzer such as PHPStan or Larastan and a scanner like OWASP ZAP help too; I did not run them for this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Laravel secure by default?
&lt;/h3&gt;

&lt;p&gt;For the common injection and CSRF cases, yes, as the tests above show. It is not secure by default in the areas that depend on your configuration and your code: debug mode, web server layout, proxy and host trust, throttling, headers and authorization.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between APP_ENV and APP_DEBUG?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;APP_DEBUG&lt;/code&gt; controls whether detailed errors are shown. &lt;code&gt;APP_ENV&lt;/code&gt; names the environment, and some framework behavior depends on it, such as trusted hosts, which is skipped in &lt;code&gt;local&lt;/code&gt;. Set both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need all of these if I'm behind Cloudflare or a load balancer?
&lt;/h3&gt;

&lt;p&gt;Yes. A proxy does not know your business rules, so it cannot fix a missing authorization check, mass assignment or unsafe SQL. It also makes the proxy-trust settings more important, not less.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should staging have debug mode off too?
&lt;/h3&gt;

&lt;p&gt;If it is reachable from the internet, yes, and it should not contain real customer data.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should I run composer audit?
&lt;/h3&gt;

&lt;p&gt;On every CI build, plus a scheduled run (daily or weekly), because advisories are published for code you did not change.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/secure-laravel-applications-before-production" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel13</category>
      <category>security</category>
      <category>production</category>
      <category>csrf</category>
    </item>
    <item>
      <title>PostgreSQL vs MySQL Performance: Real Benchmarks Compared</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Thu, 12 Nov 2026 13:42:07 +0000</pubDate>
      <link>https://dev.to/devtalk94/postgresql-vs-mysql-performance-real-benchmarks-compared-51df</link>
      <guid>https://dev.to/devtalk94/postgresql-vs-mysql-performance-real-benchmarks-compared-51df</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 7 November 2026. See the&lt;/em&gt;&lt;em&gt;update log&lt;/em&gt;&lt;em&gt;at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Which is faster, PostgreSQL or MySQL?" has no single answer, because speed depends on the query, the data, the settings and the version. Most comparisons you will find change several of those at once. So I ran both on the same machine, loaded identical data, used matching memory settings, and measured typical web-application workloads: simple reads, mixed reads and writes, reporting queries, indexes, and what happens to a table under heavy updates. The results are below, along with the places where the numbers do not mean what they seem to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;What I measured&lt;/th&gt;
&lt;th&gt;Result in this test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple point reads&lt;/td&gt;
&lt;td&gt;sysbench, 8 connections&lt;/td&gt;
&lt;td&gt;PostgreSQL about 1.7 to 2.2 times the throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed read/write transactions&lt;/td&gt;
&lt;td&gt;sysbench, 8 connections&lt;/td&gt;
&lt;td&gt;PostgreSQL about 2.1 to 2.5 times the throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregates and joins over 2M rows&lt;/td&gt;
&lt;td&gt;GROUP BY, JOIN&lt;/td&gt;
&lt;td&gt;PostgreSQL 5 to 6 times faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table size after heavy updates&lt;/td&gt;
&lt;td&gt;5 full-table updates&lt;/td&gt;
&lt;td&gt;PostgreSQL grew 30 to 113 MB; MySQL stayed at 24.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single primary-key lookup from PHP&lt;/td&gt;
&lt;td&gt;one connection, sequential&lt;/td&gt;
&lt;td&gt;MySQL about 2 times faster (0.06 vs 0.12 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of opening a connection&lt;/td&gt;
&lt;td&gt;PDO connect, localhost&lt;/td&gt;
&lt;td&gt;MySQL 0.16 ms, PostgreSQL 1.62 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is one small machine, one workload generator and two specific versions. Read the caveats before you quote any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;2 vCPU, 8 GB RAM Linux container, client and servers on the same machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;16.15 (Ubuntu package), &lt;code&gt;shared_buffers=1GB&lt;/code&gt;, &lt;code&gt;fsync=on&lt;/code&gt;, &lt;code&gt;synchronous_commit=on&lt;/code&gt;, autovacuum on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;8.0.46 (Ubuntu package), &lt;code&gt;innodb_buffer_pool_size=1G&lt;/code&gt;, &lt;code&gt;innodb_flush_log_at_trx_commit=1&lt;/code&gt;, binary log off (PostgreSQL has no equivalent second log, so I switched it off for parity)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load generator&lt;/td&gt;
&lt;td&gt;sysbench 1.0.20, 4 tables of 100,000 rows, 20 second runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other tests&lt;/td&gt;
&lt;td&gt;PHP 8.3.6 PDO, a 2,000,000-row table loaded identically into both (the &lt;code&gt;sum(amount)&lt;/code&gt; checksums matched)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both are close to default settings apart from memory. I did not tune either server for its workload. Versions matter here: MySQL 8.0 reached end of life on 30 April 2026, and the supported LTS lines are 8.4 and 9.7. PostgreSQL 16 is still supported (until November 2028), and 18 is the newest release I could confirm. Results on those newer versions may differ, so treat these as a snapshot of what Ubuntu 24.04 ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: OLTP throughput with sysbench
&lt;/h2&gt;

&lt;p&gt;sysbench's standard transactional workloads. TPS is transactions per second, so higher is better. The 8-thread figures are the first run; I repeated some workloads and give the range.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Threads&lt;/th&gt;
&lt;th&gt;PostgreSQL TPS&lt;/th&gt;
&lt;th&gt;MySQL TPS&lt;/th&gt;
&lt;th&gt;95th percentile latency (PG / MySQL)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;point_select&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;23,557&lt;/td&gt;
&lt;td&gt;17,646&lt;/td&gt;
&lt;td&gt;0.06 / 0.08 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;point_select&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;142,737 (range 120,940 to 142,737)&lt;/td&gt;
&lt;td&gt;85,080 (range 55,926 to 85,080)&lt;/td&gt;
&lt;td&gt;0.09 / 0.19 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read_only&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1,146&lt;/td&gt;
&lt;td&gt;831&lt;/td&gt;
&lt;td&gt;1.03 / 1.39 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read_only&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;5,250&lt;/td&gt;
&lt;td&gt;2,761&lt;/td&gt;
&lt;td&gt;1.86 / 4.65 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read_write&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;710&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;1.70 / 2.91 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read_write&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1,963 (range 1,694 to 1,963)&lt;/td&gt;
&lt;td&gt;780 (range 762 to 789)&lt;/td&gt;
&lt;td&gt;6.43 / 18.61 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;write_only&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1,959&lt;/td&gt;
&lt;td&gt;1,280&lt;/td&gt;
&lt;td&gt;0.67 / 1.25 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;write_only&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;6,246&lt;/td&gt;
&lt;td&gt;2,278&lt;/td&gt;
&lt;td&gt;2.03 / 7.98 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PostgreSQL was ahead in every row. Three things to know about the numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run-to-run noise is real.&lt;/strong&gt; The point-select results varied by 20 to 30 percent between runs, because the load generator shares two CPUs with the database. The ratio stayed between 1.7 and 2.2, but do not quote the absolute figures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL's&lt;/strong&gt;&lt;code&gt;performance_schema&lt;/code&gt;&lt;strong&gt;was not the reason.&lt;/strong&gt; It is on by default and costs some throughput, so I restarted MySQL with it off and reran two workloads: 78,803 TPS for point_select and 789 for read_write, in line with the earlier runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL reported a few ignored errors under write load&lt;/strong&gt;: 13 in the 8-thread read_write run and 99 in write_only, out of roughly 39,000 and 125,000 transactions. The server log showed duplicate-key errors from concurrent delete-and-reinsert of the same row, which sysbench ignores by default. MySQL reported none. They are a tiny fraction of transactions and do not change the picture, but a real application would have to retry them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result 2: reporting queries over 2 million rows
&lt;/h2&gt;

&lt;p&gt;Same table and same data in both (&lt;code&gt;events&lt;/code&gt;: id, user_id, kind, amount, created_at; plus a 50,000-row &lt;code&gt;users&lt;/code&gt; table). I timed each query nine times inside one persistent PHP PDO connection and took the median. No secondary indexes on &lt;code&gt;events&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count(*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;37 ms&lt;/td&gt;
&lt;td&gt;40 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GROUP BY kind&lt;/code&gt; with count, sum, avg&lt;/td&gt;
&lt;td&gt;120 ms&lt;/td&gt;
&lt;td&gt;616 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JOIN users GROUP BY region&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;213 ms&lt;/td&gt;
&lt;td&gt;1,321 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ORDER BY id LIMIT 20 OFFSET 1900000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;84 ms&lt;/td&gt;
&lt;td&gt;200 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyset: &lt;code&gt;WHERE id &amp;gt; 1900000 ... LIMIT 20&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.14 ms&lt;/td&gt;
&lt;td&gt;0.72 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary key lookup&lt;/td&gt;
&lt;td&gt;0.12 ms&lt;/td&gt;
&lt;td&gt;0.06 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Part of PostgreSQL's lead on the heavy queries is parallel query. With &lt;code&gt;set max_parallel_workers_per_gather=0&lt;/code&gt; its &lt;code&gt;GROUP BY&lt;/code&gt; went from 120 to 211 ms and the join from 213 to 378 ms, which is still well ahead of MySQL's single-threaded run. Two lessons from the pagination rows: a deep &lt;code&gt;OFFSET&lt;/code&gt; is slow on both (so use keyset pagination, as in &lt;a href="https://dev-talk.com/laravel-pagination-duplicate-rows-orderby" rel="noopener noreferrer"&gt;the Laravel pagination article&lt;/a&gt;), and MySQL is faster at the single primary-key lookup, though both are far below a millisecond.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: adding an index made MySQL's join slower
&lt;/h2&gt;

&lt;p&gt;This one surprised me, and it is why you should check plans instead of assuming. I created an index on &lt;code&gt;events(user_id)&lt;/code&gt; in both databases, ran &lt;code&gt;ANALYZE&lt;/code&gt;, and repeated the join:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;JOIN + GROUP BY region&lt;/th&gt;
&lt;th&gt;Before index&lt;/th&gt;
&lt;th&gt;After index on user_id&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;213 ms&lt;/td&gt;
&lt;td&gt;203 ms (same plan: hash join)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL 8.0.46&lt;/td&gt;
&lt;td&gt;1,321 ms&lt;/td&gt;
&lt;td&gt;2,094 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MySQL's plan flipped. Before the index it scanned &lt;code&gt;events&lt;/code&gt; and looked up each row's user by primary key. After it, the optimizer chose to scan &lt;code&gt;users&lt;/code&gt; and do one index lookup into &lt;code&gt;events&lt;/code&gt; per user (50,000 lookups returning about 40 rows each), which its cost estimate rated cheaper and which was slower in practice. This is MySQL 8.0.46 only; I did not test 8.4 or 9.7. The index helped exactly the query it was made for: &lt;code&gt;WHERE user_id = 123&lt;/code&gt; took 0.08 ms in MySQL and 0.13 ms in PostgreSQL. Also in my measurements, MySQL's &lt;code&gt;count(*)&lt;/code&gt; went from 40 to 124 ms after the index was added, and I did not investigate why.&lt;/p&gt;

&lt;p&gt;Building the index took 421 ms in PostgreSQL and 1,922 ms in MySQL (single runs, wall time from the command-line clients).&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 4: what repeated updates do to the table
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's design keeps old row versions in the table until VACUUM removes them. I took a 100,000-row table, disabled autovacuum for it to make the test deterministic, and ran &lt;code&gt;update sbtest1 set k = k + 1&lt;/code&gt; five times in both databases.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          PostgreSQL                         MySQL (InnoDB)
before:                   30 MB table, 4.7 MB indexes        24.5 MB table, 1.5 MB indexes
after 5 full updates:     113 MB table, 11 MB indexes        24.5 MB table, 5.0 MB indexes
                          dead_tuples = 500000
after plain VACUUM:       113 MB table, dead_tuples = 0
5 more updates:           114 MB table (space reused)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table in PostgreSQL grew to nearly four times its size, and a plain &lt;code&gt;VACUUM&lt;/code&gt; made the space reusable but did not shrink the file (&lt;code&gt;VACUUM FULL&lt;/code&gt; does, with a lock). In production autovacuum is on by default and normally keeps up, since I disabled it here to show the mechanism, but tables with heavy updates still need monitoring. In MySQL the table stayed the same size; the secondary index did grow, from 1.5 to 5.0 MB. The "before" sizes include some leftovers from the earlier benchmark runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 5: connections
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new PDO connection (localhost, TCP):   PostgreSQL 1.62 ms    MySQL 0.16 ms
"select 1" on an open connection:      PostgreSQL 0.10 ms    MySQL 0.04 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Opening a PostgreSQL connection was about ten times more expensive than a MySQL one, which matters if your application opens a connection per request without pooling. Under heavy traffic, put a pooler such as PgBouncer in front of PostgreSQL. I did not test PgBouncer. The "select 1" gap is mostly a driver and protocol effect: sysbench uses prepared statements, where PostgreSQL was faster per query, while PHP's plain &lt;code&gt;query()&lt;/code&gt; was faster on MySQL. Differences this small say more about the client than the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to run this comparison on your own data
&lt;/h2&gt;

&lt;p&gt;Benchmark your own queries on your own schema. The commands I used, with the connection details changed to yours:&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;# load the test tables (PostgreSQL shown; use --db-driver=mysql and --mysql-* options for MySQL)&lt;/span&gt;
sysbench oltp_read_write --db-driver=pgsql --pgsql-host=127.0.0.1 --pgsql-port=5432 \
  --pgsql-user=postgres --pgsql-db=sbtest --tables=4 --table-size=100000 prepare

&lt;span class="c"&gt;# run it&lt;/span&gt;
sysbench oltp_read_write --db-driver=pgsql --pgsql-host=127.0.0.1 --pgsql-port=5432 \
  --pgsql-user=postgres --pgsql-db=sbtest --tables=4 --table-size=100000 \
  --threads=8 --time=20 run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run each workload more than once, at more than one thread count, and read the "ignored errors" line. For your real queries, time them inside one open connection and read the plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SELECT&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;span class="c1"&gt;-- MySQL 8.0.18 and later&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="k"&gt;SELECT&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;span class="c1"&gt;-- watch for dead rows in PostgreSQL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common mistakes when comparing databases
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comparing different settings.&lt;/strong&gt; Match buffer memory and durability, or you are comparing configurations, not databases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing connection methods.&lt;/strong&gt; My first timings used a unix socket for one and TCP for the other, and the results were wrong. Use the same transport and a persistent connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing the client startup.&lt;/strong&gt; A new &lt;code&gt;psql&lt;/code&gt; or &lt;code&gt;mysql&lt;/code&gt; process per query measures process start, not the query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting&lt;/strong&gt;&lt;code&gt;ANALYZE&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; Planners need fresh statistics after a bulk load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting one run.&lt;/strong&gt; Repeat, and look at the spread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding an index and not re-checking the plan.&lt;/strong&gt; As Result 3 shows, an index can change a plan for the worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarking an end-of-life version.&lt;/strong&gt; MySQL 8.0 is past end of life; test the version you would actually deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Choosing: what the numbers do and do not say
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For reporting, aggregates and joins over millions of rows, PostgreSQL was clearly faster here, partly through parallel query.&lt;/li&gt;
&lt;li&gt;For many small, simple queries, both were fast, and MySQL was quicker on a single primary-key lookup and at opening connections.&lt;/li&gt;
&lt;li&gt;For tables with heavy updates, PostgreSQL needs vacuum monitoring and MySQL does not have that particular concern.&lt;/li&gt;
&lt;li&gt;Application fit usually matters more than these gaps: the team's experience, the tooling, the hosting options, and how close you already are to one of them. Laravel works with both. If you are already on MySQL 8.0, moving to 8.4 is the more urgent job (see &lt;a href="https://dev-talk.com/upgrade-mysql-8-0-to-8-4" rel="noopener noreferrer"&gt;the MySQL 8.0 to 8.4 upgrade guide&lt;/a&gt;), and most slow pages come from queries and indexes before they come from the database engine (see &lt;a href="https://dev-talk.com/laravel-13-performance-optimization" rel="noopener noreferrer"&gt;Laravel 13 performance optimization&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles and tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/upgrade-mysql-8-0-to-8-4" rel="noopener noreferrer"&gt;Upgrade MySQL 8.0 to 8.4&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/mysql-too-many-connections" rel="noopener noreferrer"&gt;MySQL "Too many connections"&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools: sysbench for load tests, &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; for plans, and PgBouncer for PostgreSQL connection pooling (not tested here).&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is PostgreSQL faster than MySQL?
&lt;/h3&gt;

&lt;p&gt;In this test, with near-default settings on a small machine, PostgreSQL 16 was faster on every sysbench workload and on aggregate and join queries. That is not a law. It depends on version, settings, hardware and workload, so test with your own queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I migrate from MySQL to PostgreSQL for speed?
&lt;/h3&gt;

&lt;p&gt;Not on these numbers alone. Fix slow queries and missing indexes first, and weigh migration cost, tooling and team experience. If you are on MySQL 8.0, upgrading to a supported version is the priority.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Laravel work with both?
&lt;/h3&gt;

&lt;p&gt;Yes, the framework supports both databases. Check the Laravel documentation for any feature that is specific to one of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about MariaDB?
&lt;/h3&gt;

&lt;p&gt;I did not test it, so I have no numbers for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Would you get the same results on bigger hardware?
&lt;/h3&gt;

&lt;p&gt;I do not know. I ran on 2 vCPUs with the load generator on the same machine; multi-core servers, faster disks and separate client machines could change the ratios.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/postgresql-vs-mysql-performance-comparison" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mysql</category>
      <category>performance</category>
      <category>benchmarks</category>
    </item>
    <item>
      <title>How to Detect and Fix PHP Memory Leaks</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Thu, 12 Nov 2026 03:24:24 +0000</pubDate>
      <link>https://dev.to/devtalk94/how-to-detect-and-fix-php-memory-leaks-4e14</link>
      <guid>https://dev.to/devtalk94/how-to-detect-and-fix-php-memory-leaks-4e14</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 5 November 2026. See the&lt;/em&gt;&lt;em&gt;update log&lt;/em&gt;&lt;em&gt;at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In a normal PHP-FPM request, a memory leak mostly does not exist: PHP frees everything when the request ends. The leaks people actually hit happen in processes that stay alive: queue workers, daemons, long import scripts, and application servers like Laravel Octane. The symptom is always the same. Memory climbs a little with every job or request, then something dies with &lt;code&gt;Allowed memory size exhausted&lt;/code&gt;, or the operating system kills the process, hours after the code that caused it ran. This article shows how to confirm that you have a leak, the four causes I would check first (each reproduced and measured), how to find the guilty objects, and how to contain a leak you cannot fix immediately.&lt;/p&gt;

&lt;p&gt;If your problem is one request using too much memory in a single go, that is not a leak. See &lt;a href="https://dev-talk.com/php-allowed-memory-size-exhausted" rel="noopener noreferrer"&gt;PHP "Allowed memory size exhausted"&lt;/a&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested
&lt;/h2&gt;

&lt;p&gt;PHP 8.3.6 CLI, and the Laravel 13.35 database and events code (the real classes, run outside a full application) on SQLite. All memory figures come from &lt;code&gt;memory_get_usage()&lt;/code&gt; unless stated. Each result is one run; the growth patterns are what matter, not the exact megabytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Confirm it is a leak, not just a big job
&lt;/h2&gt;

&lt;p&gt;A leak is memory that keeps growing as you repeat the same work. Process the same unit of work many times and print memory every so often. If it plateaus, you do not have a leak. If it rises in a straight line, you do.&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="o"&gt;&amp;lt;?&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;report&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;$label&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%s: %.1f MB (peak %.1f MB)\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$round&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$round&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$round&lt;/span&gt;&lt;span class="o"&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&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;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&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;handleOneJob&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;// your code here&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;gc_collect_cycles&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;// so cycles don't hide the real result&lt;/span&gt;
    &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"after round $round"&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 &lt;code&gt;gc_collect_cycles()&lt;/code&gt; call matters. It frees unreachable circular references, so whatever growth remains afterwards is memory that your code is still holding on to. Three PHP functions worth knowing while you measure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;memory_get_usage()&lt;/code&gt; is what your script has allocated. &lt;code&gt;memory_get_usage(true)&lt;/code&gt; is what PHP has reserved from the system, in larger chunks. In my test with one 5 MB string they reported 5.4 MB and 7.0 MB. Laravel's queue worker checks the &lt;code&gt;true&lt;/code&gt; value.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;memory_get_peak_usage()&lt;/code&gt; is the high-water mark. Since PHP 8.2, &lt;code&gt;memory_reset_peak_usage()&lt;/code&gt; resets it so you can measure one phase at a time. My test: a freed 50 MB string left the peak at 50.4 MB, and after the reset the peak read 0.4 MB.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cause 1: a static array, singleton or global that only grows
&lt;/h2&gt;

&lt;p&gt;The classic leak. Something stores data "for later" and nothing ever removes it. I simulated 50,000 units of work, each storing a 1 KB payload:&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;class&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&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;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// grows forever&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unbounded static cache:      after 10000: 12.9 MB ... after 50000: 62.5 MB
bounded cache (max 1000):    after 10000:  1.7 MB ... after 50000:  1.7 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixes, in order of preference: do not cache at all if the data is cheap to reload; bound the cache (a maximum size that evicts the oldest entry); or tie the lifetime of cached data to the object it belongs to with &lt;code&gt;WeakMap&lt;/code&gt;, which drops an entry when its key object is freed:&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;$map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WeakMap&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="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;stdClass&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="nv"&gt;$map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'expensive'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 1&lt;/span&gt;
&lt;span class="nb"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Laravel the same trap hides in singletons registered in the container, static properties on services, and traits with static state. They are harmless under PHP-FPM and dangerous under Octane or &lt;code&gt;queue:work&lt;/code&gt;. The Octane documentation (the 9.x docs page; check the current one) gives exactly this example: adding data to a static array on each request is a memory leak, because Octane keeps the application in memory between requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: circular references, and why PHP's garbage collector does not always save you
&lt;/h2&gt;

&lt;p&gt;PHP frees most values the moment nothing references them. Objects that reference each other never reach zero, so PHP has a separate cycle collector. It works, and that is why cycles are less often the culprit than people expect. I created 100,000 pairs of objects pointing at each other and threw them away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GC enabled:  after 100000: 2.5 MB   gc runs=19  collected=285000
GC disabled: after 100000: 42.1 MB  gc runs=0   roots=200000
             manual gc_collect_cycles() freed 300000; memory back to 2.4 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So with the collector on, those cycles cost nothing lasting. The catch is when it runs. The collector triggers when the number of possible cycle roots reaches a threshold (&lt;code&gt;gc_status()['threshold']&lt;/code&gt; was 10001 in my test), not when memory gets high. Large objects in cycles can therefore exhaust memory before the collector ever wakes up. I created objects that referenced themselves and each held 200 KB, with a 128 MB limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GC enabled, large self-referencing objects:
iteration 500: 96.2 MB
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted

same loop with gc_collect_cycles() every 100 iterations:
iteration 3000: 0.4 MB   finished
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a loop that creates big cyclic structures (parsed documents, large object graphs), call &lt;code&gt;gc_collect_cycles()&lt;/code&gt; every few hundred iterations, or break the cycle. Do not call it on every iteration and do not turn the collector off: it has a cost, and disabling it just made the first test above 17 times worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sneaky cycle: a closure that captures $this
&lt;/h3&gt;

&lt;p&gt;An arrow function or closure created inside a method captures &lt;code&gt;$this&lt;/code&gt; automatically. If you store that closure on the same object, you have built a cycle. I tested with &lt;code&gt;WeakReference&lt;/code&gt; to see when the object was really freed, with the collector disabled:&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;class&lt;/span&gt; &lt;span class="nc"&gt;Report&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$cb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$data&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="nf"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$static&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str_repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&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;cb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$static&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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;capturing closure: freed right after unset? NO (waits for GC)
static closure:    freed right after unset? yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a callback does not use &lt;code&gt;$this&lt;/code&gt;, declare it &lt;code&gt;static&lt;/code&gt;. It cannot create the cycle, and the object is released at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: listeners and callbacks registered over and over
&lt;/h2&gt;

&lt;p&gt;Registering an event listener inside code that runs per job or per request adds a new listener every time. Each one holds a closure and everything that closure captured. I registered one listener per "job" on Laravel's event dispatcher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;registered once:    after 20000 jobs: 0.9 MB   listeners=1
registered per job: after 20000 jobs: 16.2 MB  listeners=20000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Memory grew in a straight line, and every event dispatch also gets slower because it now calls 20,000 listeners. In Laravel, register listeners once, in a service provider's &lt;code&gt;boot()&lt;/code&gt; method or the event-to-listener mapping, never inside a job's &lt;code&gt;handle()&lt;/code&gt;, a controller, or a loop. The same applies to &lt;code&gt;Model::observe()&lt;/code&gt;, macros, and any &lt;code&gt;on()&lt;/code&gt;/&lt;code&gt;listen()&lt;/code&gt; call on a long-lived object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 4: the query log in a long-running process
&lt;/h2&gt;

&lt;p&gt;Laravel can record every query. It is off by default, which is why this shows up only when someone turns it on for debugging and forgets, or a package does. In a worker or a long command it grows without limit. I ran 20,000 trivial queries through the database connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query log off: after 20000 queries: 4.8 MB  log entries=0
query log on:  after 20000 queries: 18.2 MB  log entries=20000
after DB::connection()-&amp;gt;flushQueryLog(): 4.8 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// if you need the log inside a long loop, clear it as you go&lt;/span&gt;
&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&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="nf"&gt;enableQueryLog&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ids&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... work ...&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&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="nf"&gt;flushQueryLog&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;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&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="nf"&gt;disableQueryLog&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;Search your code and your debugging packages for &lt;code&gt;enableQueryLog&lt;/code&gt;. A related case is not a leak but looks like one: loading whole tables into memory. Use &lt;code&gt;chunkById()&lt;/code&gt;, &lt;code&gt;lazyById()&lt;/code&gt; or &lt;code&gt;cursor()&lt;/code&gt;. I measured 54 MB for &lt;code&gt;get()&lt;/code&gt; on 20,000 rows against 8 to 12 MB for the streaming options in the &lt;a href="https://dev-talk.com/laravel-13-performance-optimization" rel="noopener noreferrer"&gt;Laravel 13 performance article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find out which objects are being kept: php-meminfo
&lt;/h2&gt;

&lt;p&gt;When the usual suspects do not explain it, you need to see what is in memory. The php-meminfo extension dumps every live item in a process to JSON. I built it from source against PHP 8.3.6 (&lt;code&gt;phpize&lt;/code&gt;, &lt;code&gt;./configure --enable-meminfo&lt;/code&gt;, &lt;code&gt;make&lt;/code&gt;) and ran a dump after creating 1,500 objects that were kept and 300 that were not:&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;// php -d extension=/path/to/meminfo.so script.php&lt;/span&gt;
&lt;span class="nv"&gt;$fh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/tmp/dump.json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;meminfo_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fh&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;Counting the objects by class in that dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[('Order', 1500), ('Tmp', 1)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The class that has far more instances than it should is your leak. The project ships an analyzer for this; its README documents the commands &lt;code&gt;bin/analyzer summary&lt;/code&gt;, &lt;code&gt;top-children&lt;/code&gt;, &lt;code&gt;query -f "class=Order"&lt;/code&gt; and &lt;code&gt;ref-path &amp;lt;item-id&amp;gt;&lt;/code&gt;, where &lt;code&gt;ref-path&lt;/code&gt; shows what is still referencing a given object. I could not run the analyzer (it needs Composer packages that I could not download), so I counted classes with a short script instead.&lt;/p&gt;

&lt;p&gt;Two warnings. The README lists PHP 7.x and 8.0 and the last commit I saw was from March 2022, so treat it as an unmaintained tool: it worked for a dump on 8.3.6 in my test, but try it on a staging copy first. And dumps of a real worker are large, so never run it in production traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contain a leak you cannot fix today
&lt;/h2&gt;

&lt;p&gt;Recycling the process is a legitimate stopgap, as long as you treat it as a stopgap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel queue workers.&lt;/strong&gt; From the Laravel 13 source:&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 queue:work --memory=256 --max-jobs=1000 --max-time=3600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--memory&lt;/code&gt; defaults to 128 (MB). In the 13.x source it also accepts a percentage of PHP's &lt;code&gt;memory_limit&lt;/code&gt;, such as &lt;code&gt;50%&lt;/code&gt;. The worker compares &lt;code&gt;memory_get_usage(true)&lt;/code&gt; in MB with the limit, and when it is reached the worker exits with status code 12.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--max-jobs&lt;/code&gt; and &lt;code&gt;--max-time&lt;/code&gt; stop the worker after that many jobs or seconds.&lt;/li&gt;
&lt;li&gt;A stopped worker needs something to start it again, such as Supervisor or systemd. See &lt;a href="https://dev-talk.com/laravel-queue-jobs-not-processing" rel="noopener noreferrer"&gt;Laravel queue jobs not processing or stuck&lt;/a&gt;. Set &lt;code&gt;--memory&lt;/code&gt; below the PHP &lt;code&gt;memory_limit&lt;/code&gt;, so the worker exits cleanly between jobs instead of dying in the middle of one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;PHP-FPM.&lt;/strong&gt; In a pool configuration, &lt;code&gt;pm.max_requests&lt;/code&gt; is the number of requests a child handles before it is respawned. The PHP manual says this can be useful to work around memory leaks in third-party libraries, and the default is 0, meaning never respawn:&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="na"&gt;pm.max_requests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Octane.&lt;/strong&gt; The 9.x documentation says Octane restarts a worker after 500 requests by default, adjustable with &lt;code&gt;php artisan octane:start --max-requests=250&lt;/code&gt;. I did not run Octane for this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trusting a single measurement.&lt;/strong&gt; One reading proves nothing. Repeat the work and watch the trend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measuring without collecting cycles first.&lt;/strong&gt; You may be chasing garbage that PHP was about to free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calling&lt;/strong&gt;&lt;code&gt;unset()&lt;/code&gt;&lt;strong&gt;and expecting the process size to shrink.&lt;/strong&gt; PHP reuses freed memory internally. Judge by &lt;code&gt;memory_get_usage()&lt;/code&gt; across rounds, not by the process size in &lt;code&gt;top&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disabling the garbage collector for speed.&lt;/strong&gt; My cycle test shows what that costs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raising&lt;/strong&gt;&lt;code&gt;memory_limit&lt;/code&gt;&lt;strong&gt;instead of finding the growth.&lt;/strong&gt; It only delays the crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving debug tools on.&lt;/strong&gt; Query logging, event recording and similar tools hold everything they record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating restarts as the fix.&lt;/strong&gt; &lt;code&gt;--max-jobs&lt;/code&gt; and &lt;code&gt;pm.max_requests&lt;/code&gt; hide the leak, and a leak that grows fast enough still kills the process between restarts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles and tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/php-allowed-memory-size-exhausted" rel="noopener noreferrer"&gt;PHP "Allowed memory size exhausted"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/laravel-queue-jobs-not-processing" rel="noopener noreferrer"&gt;Laravel queue jobs not processing or stuck&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/docker-build-taking-too-long" rel="noopener noreferrer"&gt;Docker build taking too long? 10 ways to speed it up&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools: &lt;code&gt;memory_get_usage()&lt;/code&gt;, &lt;code&gt;memory_reset_peak_usage()&lt;/code&gt; and &lt;code&gt;gc_status()&lt;/code&gt; (built in and used above), the php-meminfo extension, and a memory-capable profiler such as Blackfire. I did not use a profiler for this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does PHP have memory leaks?
&lt;/h3&gt;

&lt;p&gt;PHP itself frees all request memory at the end of a request, so ordinary PHP-FPM apps rarely leak in the long-term sense. Long-running processes can accumulate memory through your code (static data, listeners, logs), through reference cycles that have not been collected yet, and occasionally through bugs in extensions or libraries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I call gc_collect_cycles() in my code?
&lt;/h3&gt;

&lt;p&gt;Only in long loops that create large cyclic structures, and every few hundred iterations rather than every iteration. My test showed it can be the difference between finishing and a fatal error. For small objects the automatic collector is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the process memory in top not go down after unset()?
&lt;/h3&gt;

&lt;p&gt;PHP keeps freed memory to reuse it. &lt;code&gt;memory_get_usage(true)&lt;/code&gt; reports memory PHP has reserved, which is larger than what your data uses (7.0 against 5.4 MB in my test). Judge growth by the trend across rounds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Xdebug the right tool for finding leaks?
&lt;/h3&gt;

&lt;p&gt;I did not test it for this. I used built-in memory functions to find the trend and php-meminfo to see what is held, so use those first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a memory limit on queue workers a real fix?
&lt;/h3&gt;

&lt;p&gt;No. It protects you from a crash by restarting cleanly. Use it as a safety net while you find the cause.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/detect-fix-memory-leaks-php-applications" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>memoryleaks</category>
      <category>laravel</category>
      <category>queueworkers</category>
    </item>
    <item>
      <title>Docker Build Too Slow? 10 Ways to Speed It Up</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Wed, 11 Nov 2026 14:53:07 +0000</pubDate>
      <link>https://dev.to/devtalk94/docker-build-too-slow-10-ways-to-speed-it-up-4aon</link>
      <guid>https://dev.to/devtalk94/docker-build-too-slow-10-ways-to-speed-it-up-4aon</guid>
      <description>&lt;p&gt;A slow Docker build usually has one of a few causes: you are sending far more files to the builder than you need, a cheap change at the top of the Dockerfile throws away an expensive step below it, or every build re-downloads packages it fetched an hour ago. All of these are fixable without changing your application. This article goes through ten fixes in the order I would try them. Most of them I ran and timed on a real Docker Engine, and I show the output, so you can compare it with what you see on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: how I tested, and what the numbers mean
&lt;/h2&gt;

&lt;p&gt;Docker Engine 29.8.2, Buildx 0.37.1, BuildKit 0.33.1, on Linux with the containerd image store. The environment could not pull images from Docker Hub, so I built a tiny local base image (a shell plus a few core tools) and used &lt;code&gt;sleep 4&lt;/code&gt; as a stand-in for a slow dependency install. That makes the cache behaviour real, because the cache does not care what a step does, but the absolute timings are not what &lt;code&gt;npm install&lt;/code&gt; or &lt;code&gt;composer install&lt;/code&gt; will take on your project. Every timing is a single run. Look at the ratios, then measure your own project.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Find out which step is slow before changing anything
&lt;/h2&gt;

&lt;p&gt;Run the build with plain progress output. It prints each step and how long it took, and marks steps served from cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build --progress=plain -t myapp .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#6 [2/5] WORKDIR /app
#6 CACHED
#8 [4/5] RUN sleep 4 &amp;amp;&amp;amp; mkdir -p node_modules
#8 CACHED
#9 [5/5] COPY src ./src
#9 DONE 0.0s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for three things: &lt;code&gt;transferring context&lt;/code&gt; with a large size (see technique 2), steps near the top that are not &lt;code&gt;CACHED&lt;/code&gt; on a rebuild (techniques 3 and 9), and a single step that dominates the total (techniques 4 and 5). The default fancy progress view hides most of this, so use &lt;code&gt;--progress=plain&lt;/code&gt; while you investigate.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Add a .dockerignore file
&lt;/h2&gt;

&lt;p&gt;When your Dockerfile has &lt;code&gt;COPY . .&lt;/code&gt;, Docker has to send everything in the build directory to the builder: &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;, logs, local build output. I made a project with 450 MB of that kind of material and one small source file, then built with &lt;code&gt;COPY . .&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;without .dockerignore:  transferring context: 472.0MB   total 33.9 s
with .dockerignore:     transferring context: 254B       total  1.0 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A starting point for a Laravel project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.git
node_modules
vendor
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
.env
.env.*
*.log
.idea
.vscode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Excluding &lt;code&gt;.env&lt;/code&gt; matters for more than speed: otherwise &lt;code&gt;COPY . .&lt;/code&gt; bakes your local secrets into an image layer. Only exclude &lt;code&gt;vendor&lt;/code&gt; if the image installs its own dependencies, as in the example near the end. One detail from my testing: BuildKit transfers only the files a &lt;code&gt;COPY&lt;/code&gt; actually references. With &lt;code&gt;COPY src ./src&lt;/code&gt; the same 450 MB project transferred 79 bytes, so the huge-context problem shows up with &lt;code&gt;COPY . .&lt;/code&gt;, which is what most Dockerfiles use.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Order layers from least to most frequently changed
&lt;/h2&gt;

&lt;p&gt;Docker reuses a cached layer only if the instruction and everything before it are unchanged. If &lt;code&gt;COPY . .&lt;/code&gt; comes first, any source edit invalidates the dependency install after it. Here is the same 4-second "install" step in both orders, timed after changing one line of source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Slow: source copied first&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; mini:1
&lt;span class="k"&gt;WORKDIR&lt;/span&gt; /app
&lt;span class="k"&gt;COPY&lt;/span&gt; . .
&lt;span class="k"&gt;RUN&lt;/span&gt; sleep 4 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mkdir -p node_modules

&lt;span class="c"&gt;# Fast: manifest first, install, then source&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; mini:1
&lt;span class="k"&gt;WORKDIR&lt;/span&gt; /app
&lt;span class="k"&gt;COPY&lt;/span&gt; package.json .
&lt;span class="k"&gt;RUN&lt;/span&gt; sleep 4 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mkdir -p node_modules
&lt;span class="k"&gt;COPY&lt;/span&gt; src ./src
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slow order, after a source edit: 4.6 s
fast order, after a source edit: 0.3 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For real projects this means copying &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;package-lock.json&lt;/code&gt;, or &lt;code&gt;composer.json&lt;/code&gt; and &lt;code&gt;composer.lock&lt;/code&gt;, before the rest of the code, and running the install in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Use cache mounts for package downloads
&lt;/h2&gt;

&lt;p&gt;Layer caching helps until the manifest changes. Add one dependency and the whole install reruns, re-downloading everything. A BuildKit cache mount keeps the package manager's download cache between builds, outside the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt; package.json package-lock.json ./
&lt;span class="k"&gt;RUN&lt;/span&gt; --mount=type=cache,target=/root/.npm npm ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested the mechanism with a step that "downloads" for 4 seconds only when its cache directory is empty, and changed &lt;code&gt;package.json&lt;/code&gt; between builds so the layer was invalidated each time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;without cache mount: first 4.7 s | after package.json change 4.5 s
with cache mount:    first 4.7 s | after package.json change 0.5 s
after docker builder prune -af:   4.7 s again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Docker documentation lists cache targets for common tools: &lt;code&gt;/root/.npm&lt;/code&gt; for npm, &lt;code&gt;/root/.cache/pip&lt;/code&gt; for pip, &lt;code&gt;/root/.gem&lt;/code&gt; for Bundler, &lt;code&gt;/go/pkg/mod&lt;/code&gt; and &lt;code&gt;/root/.cache/go-build&lt;/code&gt; for Go, &lt;code&gt;/tmp/cache&lt;/code&gt; for Composer, and for apt &lt;code&gt;/var/cache/apt&lt;/code&gt; and &lt;code&gt;/var/lib/apt&lt;/code&gt; with &lt;code&gt;sharing=locked&lt;/code&gt;. For Composer I would set the directory explicitly rather than rely on a default, using Composer's documented &lt;code&gt;COMPOSER_CACHE_DIR&lt;/code&gt; variable (I confirmed that the variable changes &lt;code&gt;composer config cache-dir&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ENV&lt;/span&gt; &lt;span class="nv"&gt;COMPOSER_CACHE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/cache
&lt;span class="k"&gt;RUN&lt;/span&gt; --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two cautions. The cache lives in the builder's storage, not in the image, so a fresh CI runner starts with it empty (see technique 8). And as the test shows, pruning the builder cache wipes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Split independent work into stages: BuildKit builds them in parallel
&lt;/h2&gt;

&lt;p&gt;A multi-stage build keeps compilers and dev tools out of the final image, and BuildKit also runs stages that do not depend on each other at the same time. A single-stage Dockerfile runs its steps one after another. I used two independent 3-second steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt; mini:1 AS front
&lt;span class="k"&gt;RUN&lt;/span&gt; sleep 3 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; front &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /front.txt

&lt;span class="k"&gt;FROM&lt;/span&gt; mini:1 AS back
&lt;span class="k"&gt;RUN&lt;/span&gt; sleep 3 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; back &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /back.txt

&lt;span class="k"&gt;FROM&lt;/span&gt; mini:1
&lt;span class="k"&gt;COPY&lt;/span&gt; --from=front /front.txt /
&lt;span class="k"&gt;COPY&lt;/span&gt; --from=back /back.txt /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;both steps in one stage (serial):   6.9 s
two independent stages (parallel):  3.7 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real-world version is a Laravel image where Composer dependencies and frontend assets are separate stages. A fuller example is near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Create and delete in the same layer
&lt;/h2&gt;

&lt;p&gt;Deleting a file in a later &lt;code&gt;RUN&lt;/code&gt; does not shrink the image; the earlier layer still holds it. Bigger images mean slower pushes, pulls and CI cache uploads. I created a 100 MB temporary file and removed it, once in two steps and once in one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;two RUN steps:
 8.19kB   RUN rm /tmp/build.tmp
 105MB    RUN dd if=/dev/urandom of=/tmp/build.tmp ...

one RUN step (create and rm joined with &amp;amp;&amp;amp;):
 16.4kB   RUN dd if=/dev/urandom of=/tmp/build.tmp ... &amp;amp;&amp;amp; rm /tmp/build.tmp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is output from &lt;code&gt;docker history&lt;/code&gt;. Use it rather than the total size from &lt;code&gt;docker image inspect&lt;/code&gt; for this comparison: on this containerd-store install the inspect size was larger than the sum of the layers, so I did not trust it. Typical real cases are archive downloads you extract and delete, and build tools installed for one compile step.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Choose a smaller, pinned base image
&lt;/h2&gt;

&lt;p&gt;This one I could not measure, because I could not pull images here. The reasoning is straightforward: the base image is pulled on every fresh machine, so a smaller one means faster first builds and pulls. Pin an explicit version tag (for example a specific PHP or Node version) instead of &lt;code&gt;latest&lt;/code&gt;, so a base image update does not silently invalidate your whole cache or change behavior. Slim and Alpine variants are smaller, but they differ from the full images (Alpine uses musl instead of glibc, which can break some native extensions), so test your app before switching. Also keep your language version supported: see the &lt;a href="https://dev-talk.com/php-8-2-end-of-life" rel="noopener noreferrer"&gt;PHP 8.2 end-of-life&lt;/a&gt; and &lt;a href="https://dev-talk.com/nodejs-20-end-of-life-upgrade-node-24" rel="noopener noreferrer"&gt;Node.js 20 end-of-life&lt;/a&gt; articles.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Share the build cache in CI
&lt;/h2&gt;

&lt;p&gt;CI runners are usually fresh machines, so every build starts with an empty cache and rebuilds everything. Buildx can export the cache and import it on the next run. The local form is easy to try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build --cache-to &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;local,dest=./cache,mode=max -t myapp .

&lt;span class="c"&gt;# later, on another machine or after a prune&lt;/span&gt;
docker buildx build --cache-from &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;local,src=./cache -t myapp .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this, pruned the whole builder cache in between, and the second build finished in 0.4 s with every step marked &lt;code&gt;CACHED&lt;/code&gt;. In CI you would store that directory with your CI's cache feature, or use a registry or the GitHub Actions backend. The Docker documentation's GitHub Actions example is:&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="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v7&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;registry&amp;gt;/&amp;lt;image&amp;gt;:latest"&lt;/span&gt;
    &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
    &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not run this on GitHub Actions. The same documentation page states that the &lt;code&gt;gha&lt;/code&gt; backend works with the default &lt;code&gt;docker&lt;/code&gt; driver only when the containerd image store is enabled; otherwise use a different driver. &lt;code&gt;mode=max&lt;/code&gt; exports the layers of intermediate stages too, which matters for multi-stage builds, at the cost of a larger cache. If you build several images in one workflow, give each a separate &lt;code&gt;scope&lt;/code&gt;, otherwise each build overwrites the previous cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Stop accidentally invalidating the cache
&lt;/h2&gt;

&lt;p&gt;These are the usual culprits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A changing&lt;/strong&gt;&lt;code&gt;ARG&lt;/code&gt;&lt;strong&gt;near the top.&lt;/strong&gt; A build timestamp or commit SHA passed as an &lt;code&gt;ARG&lt;/code&gt; invalidates every step after it. In my test, the same build with the same argument value was fully cached (0.3 s), and changing only the value rerun the expensive step (3.5 s). Declare such arguments at the end, just before the instruction that needs them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COPY . .&lt;/code&gt;&lt;strong&gt;before the install&lt;/strong&gt;, as in technique 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copying files that change on every build&lt;/strong&gt;, such as logs, caches or a generated file, which also belong in &lt;code&gt;.dockerignore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splitting&lt;/strong&gt;&lt;code&gt;apt-get update&lt;/code&gt;&lt;strong&gt;from&lt;/strong&gt;&lt;code&gt;apt-get install&lt;/code&gt; in different &lt;code&gt;RUN&lt;/code&gt; steps, which can leave a stale package list cached. Keep them in one step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docker can also lint the Dockerfile for common mistakes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build --check .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Check complete, 2 warnings have been found!
WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
WARNING: FromAsCasing - https://docs.docker.com/go/dockerfile/rule/from-as-casing/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two rules are style and signal-handling warnings rather than speed problems, but the check is quick and catches real mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Watch the builder's cache, and prune on purpose
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx du        &lt;span class="c"&gt;# what the build cache holds&lt;/span&gt;
docker builder prune    &lt;span class="c"&gt;# remove unused build cache&lt;/span&gt;
docker builder prune -af   &lt;span class="c"&gt;# remove all of it, no prompt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A full prune explains "why is my build suddenly slow again" when it follows a disk cleanup, since it also empties your cache mounts, as the technique 4 test showed. If disk space is the reason you prune, consider removing only old cache instead of everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: a Laravel Dockerfile
&lt;/h2&gt;

&lt;p&gt;This combines techniques 3, 4, 5 and 9 for a Laravel app. I did not build this exact file, because the official PHP, Node and Composer images could not be pulled here; each technique in it was tested separately above. Check the tags and the files your frontend build reads (the config files, &lt;code&gt;resources&lt;/code&gt;) against your project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt; composer:2 AS vendor
&lt;span class="k"&gt;WORKDIR&lt;/span&gt; /app
&lt;span class="k"&gt;ENV&lt;/span&gt; &lt;span class="nv"&gt;COMPOSER_CACHE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/cache
&lt;span class="k"&gt;COPY&lt;/span&gt; composer.json composer.lock ./
&lt;span class="k"&gt;RUN&lt;/span&gt; --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist
&lt;span class="k"&gt;COPY&lt;/span&gt; . .
&lt;span class="k"&gt;RUN&lt;/span&gt; composer dump-autoload --optimize --no-dev

&lt;span class="k"&gt;FROM&lt;/span&gt; node:24 AS assets
&lt;span class="k"&gt;WORKDIR&lt;/span&gt; /app
&lt;span class="k"&gt;COPY&lt;/span&gt; package.json package-lock.json ./
&lt;span class="k"&gt;RUN&lt;/span&gt; --mount=type=cache,target=/root/.npm npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt; vite.config.js ./
&lt;span class="k"&gt;COPY&lt;/span&gt; resources ./resources
&lt;span class="k"&gt;RUN&lt;/span&gt; npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt; php:8.4-fpm AS app
&lt;span class="k"&gt;WORKDIR&lt;/span&gt; /var/www/html
&lt;span class="k"&gt;RUN&lt;/span&gt; docker-php-ext-install pdo_mysql
&lt;span class="k"&gt;COPY&lt;/span&gt; --from=vendor /app ./
&lt;span class="k"&gt;COPY&lt;/span&gt; --from=assets /app/public/build ./public/build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;vendor&lt;/code&gt; and &lt;code&gt;assets&lt;/code&gt; stages do not depend on each other, so BuildKit can run them at the same time. The slow system-level step (&lt;code&gt;docker-php-ext-install&lt;/code&gt;) comes before any application code, so it stays cached across code changes. After the build, the image contains neither Composer's cache nor &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Optimizing without reading the timings.&lt;/strong&gt; Use &lt;code&gt;--progress=plain&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copying the whole project before installing dependencies.&lt;/strong&gt; It is the most common reason the cache never hits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using&lt;/strong&gt;&lt;code&gt;--no-cache&lt;/code&gt;&lt;strong&gt;as a habit.&lt;/strong&gt; It is a debugging tool. It rebuilds everything every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting cache mounts to survive on a fresh CI runner.&lt;/strong&gt; They live in the builder's storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving&lt;/strong&gt;&lt;code&gt;.env&lt;/code&gt;&lt;strong&gt;out of&lt;/strong&gt;&lt;code&gt;.dockerignore&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; Secrets end up in image layers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using&lt;/strong&gt;&lt;code&gt;latest&lt;/code&gt;&lt;strong&gt;as the base tag.&lt;/strong&gt; A base image update can change behavior and bust the cache without any change in your repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles and tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/docker-vs-kubernetes-difference" rel="noopener noreferrer"&gt;Docker vs Kubernetes: what's the difference?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/post/upgrade-laravel-12-to-13" rel="noopener noreferrer"&gt;Upgrade Laravel 12 to 13, step by step&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev-talk.com/post/php-8-2-end-of-life" rel="noopener noreferrer"&gt;PHP 8.2 end of life&lt;/a&gt; and &lt;a href="https://dev-talk.com/post/nodejs-20-end-of-life-upgrade-node-24" rel="noopener noreferrer"&gt;Node.js 20 end of life&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools: &lt;code&gt;docker build --check&lt;/code&gt; and &lt;code&gt;docker buildx du&lt;/code&gt; (both used above), plus the Dockerfile linter Hadolint and the image layer explorer Dive. I did not run the last two for this article.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/docker-build-taking-too-long" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>buildkit</category>
      <category>dockerfile</category>
      <category>cicd</category>
    </item>
    <item>
      <title>AI Coding Prompts That Actually Improve Productivity</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Tue, 10 Nov 2026 14:16:15 +0000</pubDate>
      <link>https://dev.to/devtalk94/ai-coding-prompts-that-actually-improve-productivity-3i20</link>
      <guid>https://dev.to/devtalk94/ai-coding-prompts-that-actually-improve-productivity-3i20</guid>
      <description>&lt;p&gt;Most lists of "best AI coding prompts" are collections of clever wording. Wording is rarely the problem. The real problem is that AI output still has to be checked by you, and checking is the slow part. A prompt that produces more code faster but leaves you reading 400 unfamiliar lines has not saved time. This article is about prompts that make the result cheaper to verify, and every example was run against a small PHP project so you can see the actual output, including where a prompt did not make the difference I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "faster code" is not the same as productivity
&lt;/h2&gt;

&lt;p&gt;The research does not support a simple "AI makes developers faster" story. In METR's 2025 randomized trial with 16 experienced open-source developers working on their own repositories, developers took 19% longer with AI tools than without, while believing afterwards that the tools had made them about 20% faster. METR's February 2026 follow-up used a larger group (57 developers, 800+ tasks) and found changes of -18% and -4% in task time for the two groups, which would mean faster, but neither result was statistically significant, and METR listed design problems: between 30% and 50% of developers said they skipped tasks they did not want to do without AI, and time measurements get unreliable when developers run several agents at once. METR's own conclusion is that developers are likely more sped up now, but it cannot show that with the current data. (I read the 2026 figures in a secondary write-up of METR's post, so check METR's site for the original numbers.)&lt;/p&gt;

&lt;p&gt;The 2025 DORA report on AI-assisted development points the same direction from the team side: AI improves throughput, but often at the cost of stability when the underlying engineering foundation is weak. And the Stack Overflow Developer Survey 2026 shows what developers do in practice after an AI answer arrives: 76.5% run the code locally, 63.9% compare it against their codebase, 53.0% inspect tests and security implications, and only 9.6% use it as-is.&lt;/p&gt;

&lt;p&gt;So the useful question is not "how do I get more code out of the model" but "how do I get output I can trust quickly". The prompts below are built around that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five habits that did the work
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Give it a check it can run.&lt;/strong&gt; A failing test, a command, a linter. This matters more than anything you write in the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what it must not touch.&lt;/strong&gt; "Do not edit anything in tests/" costs ten words and prevents the classic cheat of changing the test to pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope the task.&lt;/strong&gt; One bug, one file, one concern. Say "security and performance only; ignore style".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for evidence in a fixed format.&lt;/strong&gt; Line numbers, severity, the command it ran, the exact line it changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask it to report, not fix, when you want to decide.&lt;/strong&gt; Tests that expose problems are more valuable than silent patches.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The test project
&lt;/h2&gt;

&lt;p&gt;All examples use this tiny project. &lt;code&gt;src/Cart.php&lt;/code&gt; has one real bug: it truncates instead of rounding half up.&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="o"&gt;&amp;lt;?&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt;
&lt;span class="c1"&gt;// src/Cart.php&lt;/span&gt;
&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/** @param array&amp;lt;int, array{price_cents:int, qty:int}&amp;gt; $items */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$discountPercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'price_cents'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'qty'&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;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$discountPercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&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;&lt;code&gt;tests/run.php&lt;/code&gt; is a plain script with a small &lt;code&gt;check($name, $expected, $actual)&lt;/code&gt; function and three checks. Running it fails on exactly one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php tests/run.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS  no discount
FAIL  10% off, rounds half up (expected 896, got 895)
PASS  empty cart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran every prompt with Claude Code 2.1.318 in print mode, giving it only the tools it needed, for example &lt;code&gt;--permission-mode acceptEdits --allowedTools "Read Edit Bash(php *)"&lt;/code&gt;. The prompts are plain text and not specific to that tool, but I only ran them in Claude Code. Model output varies from run to run, so treat each result as one sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt 1: fixing a bug
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The vague version:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The cart total is wrong, fix it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The specific version:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Running 'php tests/run.php' fails: the test '10% off, rounds half up' expects 896
but total() returns 895 for one item of 995 cents with a 10% discount. Fix the bug
in src/Cart.php only: do not edit anything in tests/. Money is integer cents, so
do not introduce floats into the return type. Run 'php tests/run.php' to confirm
all tests pass, then answer in two sentences: the root cause, and the exact line
you changed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What happened:&lt;/strong&gt; both fixed it, and both left the test file untouched (I compared checksums). The vague prompt worked because the failing test was already in the repository, and the agent found and ran it. That is the first lesson: &lt;em&gt;the test did the real work, not the wording&lt;/em&gt;. The differences were smaller than I expected:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Vague prompt&lt;/th&gt;
&lt;th&gt;Specific prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Result&lt;/td&gt;
&lt;td&gt;All 3 tests pass&lt;/td&gt;
&lt;td&gt;All 3 tests pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change&lt;/td&gt;
&lt;td&gt;Replaced the return line and added a comment&lt;/td&gt;
&lt;td&gt;Replaced the return line only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer&lt;/td&gt;
&lt;td&gt;A long explanation with assumptions and an offer to look at tax, shipping and negative quantities&lt;/td&gt;
&lt;td&gt;Two sentences: root cause and the exact line&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both used the same fix:&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;return&lt;/span&gt; &lt;span class="nf"&gt;intdiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$discountPercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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 specific prompt gave me a shorter, easier-to-review answer and a smaller diff. It did not give a more correct fix. Which brings us to the next prompt, because this fix has a problem the original test never covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt 2: edge-case tests that report instead of fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write edge-case tests for Cart::total() in tests/edge.php, reusing the check() style
from tests/run.php. Cover: quantity 0, a 100% discount, a discount over 100, a
negative price, several items in one cart, and a large quantity. Run 'php tests/edge.php'.
Do NOT modify anything in src/. For each failing test, say whether the bug is in
Cart or in my expectation, and what behaviour you think Cart should have.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this against the code after the fix above. Five tests passed, three failed, &lt;code&gt;src/&lt;/code&gt; was untouched (checksum compared), and I re-ran the file myself to confirm the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS  qty 0 gives 0
PASS  qty 0 with discount gives 0
PASS  100% discount is free
FAIL  discount 150 is rejected (expected 'InvalidArgumentException', got -499)
FAIL  negative price is rejected (expected 'InvalidArgumentException', got -499)
PASS  several items, 10% off
PASS  large quantity
FAIL  overflowing quantity is rejected (expected 'OverflowException', got 'TypeError')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful part was the triage. For a discount of 150 the agent explained that &lt;code&gt;1000 * (100 - 150) + 50&lt;/code&gt; is &lt;code&gt;-49950&lt;/code&gt; and &lt;code&gt;intdiv&lt;/code&gt; truncates toward zero, so the customer gets a negative total, and it argued for throwing on a discount outside 0-100. For the negative price it pointed out that the &lt;code&gt;+ 50&lt;/code&gt; rounding trick is only correct for non-negative sums: a single item of -500 with no discount returns -499. That is a genuine bug that &lt;em&gt;the fix itself introduced&lt;/em&gt;, and it was invisible to the original test. For the overflow test it correctly said the failure was mostly its own expectation: the code fails loudly with a &lt;code&gt;TypeError&lt;/code&gt; and never returns a wrong number, and the exception class I asked for was an arbitrary choice.&lt;/p&gt;

&lt;p&gt;This is the main reason to ask for tests and a verdict instead of a patch: the patch that passes your test may still be wrong, and you only learn that if something probes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt 3: structured code review
&lt;/h2&gt;

&lt;p&gt;I used a small Laravel controller with real problems: a SQL string built from request input, &lt;code&gt;Order::find($id)-&amp;gt;delete()&lt;/code&gt; with no existence or authorization check, an N+1 loop over relations, and an unbounded &lt;code&gt;get()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vague:&lt;/strong&gt; &lt;code&gt;review src/OrderController.php&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Review src/OrderController.php (a Laravel 13 controller) for security and performance
problems only; ignore style. For each finding give: severity (high/medium/low), the
line number, why it is a problem in one sentence, and a minimal fix as a code snippet.
Order by severity. Do not report anything you cannot point to a specific line for.
Do not edit any files.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both reviews found the SQL injection, the missing authorization on delete, the crash on a missing ID, and the N+1 queries. I checked the line numbers in the structured answer (13, 19-20, 31 and 36) against the file and they were right. So this prompt did not find more bugs. What it changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The vague review returned seven findings, including a "minor" group, and a long fix-priority list. The structured one returned five findings in a fixed format with line numbers, which is easier to turn into tickets.&lt;/li&gt;
&lt;li&gt;It stated what it could not judge: "I can't see the routes file or policies. If a middleware or policy already ..." That sentence is worth more than the extra findings, because it tells you what to verify yourself.&lt;/li&gt;
&lt;li&gt;It left out two possible issues it could not tie to a specific line, which is exactly what the "do not report anything you cannot point to" instruction asked for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixes it proposed for authorization are sketches (they assume an &lt;code&gt;OrderPolicy&lt;/code&gt; exists). Treat review output as a list of places to look, not as a patch to apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  More prompt templates (not run for this article)
&lt;/h2&gt;

&lt;p&gt;These follow the same five habits. I have not tested them here, so adapt them and check the results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Plan before code
Plan how to add [feature]. List the files you would change, the new tests you would
write, and the risks. Do not write any code yet.

# Refactor with a safety net
Refactor [class] to [goal]. Behaviour must not change. Run [test command] before and
after, and show both results. Stop and tell me if any test fails.

# Explain unfamiliar code
Explain what [function] does in five bullets. Flag side effects (database writes,
network calls, global state) and anything that looks like a bug. Do not change code.

# SQL review
Here is a query and its EXPLAIN output: [paste]. Tell me what is slow and why, and
suggest an index. Say which assumptions you are making about the table size.

# Commit message
Write a commit message for the staged changes ('git diff --staged'). Subject under
72 characters, imperative mood, then a short body explaining why, not what.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treating a passing test as proof.&lt;/strong&gt; The fix in Prompt 1 passed and still broke on negative amounts. Add edge cases and read the assertions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No way for the agent to check itself.&lt;/strong&gt; Without a test command, the answer is a guess presented confidently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts that invite scope creep.&lt;/strong&gt; "Improve this class" produces a rewrite. Name one concern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not saying what to leave alone.&lt;/strong&gt; Forbid edits to tests, migrations or config explicitly when that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accepting fix sketches as final.&lt;/strong&gt; Generated authorization and validation code assumes things about your app that it cannot see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never measuring.&lt;/strong&gt; If you do not track review time and defects, you cannot tell whether a habit helps. The METR results show that feelings about speed can be wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alternatives to writing better prompts every time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put standing rules in a project file&lt;/strong&gt; (&lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;): the test command, the formatter, directories not to touch. Then you do not repeat them in every prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a tool that gives the agent project context.&lt;/strong&gt; For Laravel, Laravel Boost does this; see &lt;a href="https://dev-talk.com/ai-agents-build-laravel-applications" rel="noopener noreferrer"&gt;How to use AI agents to build Laravel applications&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate the checks.&lt;/strong&gt; A formatter, static analysis and a test suite that run on every change catch more than any prompt wording.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles and tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/claude-code-commands-every-developer-should-know" rel="noopener noreferrer"&gt;10 Claude Code commands every developer should know&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/claude-code-vs-cursor-vs-github-copilot" rel="noopener noreferrer"&gt;Claude Code vs Cursor vs GitHub Copilot: which is best in 2026?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/ai-agents-build-laravel-applications" rel="noopener noreferrer"&gt;How to use AI agents to build Laravel applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/open-source-tools-developer-productivity" rel="noopener noreferrer"&gt;Open source tools that make developers more productive&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools that make AI output cheaper to verify: your test runner (Pest or PHPUnit), Laravel Pint for formatting, and a static analyzer such as PHPStan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do these prompts work with Cursor and Copilot?
&lt;/h3&gt;

&lt;p&gt;The wording is tool-agnostic, and the five habits apply to any assistant. I only ran them in Claude Code, so I cannot show results for the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should a prompt be?
&lt;/h3&gt;

&lt;p&gt;Long enough to state the failing check, the constraints and the output format. The specific bug-fix prompt above is five sentences. Length beyond that rarely helped in my runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a vague prompt ever fine?
&lt;/h3&gt;

&lt;p&gt;Yes. When the repository already contains a failing test, a short prompt worked. Vague prompts get risky when there is nothing for the agent to check against.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I ask the AI to explain its reasoning?
&lt;/h3&gt;

&lt;p&gt;Ask for evidence you can verify (the line it changed, the command it ran, the output), not for a story. A confident explanation can be wrong; a test result can be rerun.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will good prompts make me faster?
&lt;/h3&gt;

&lt;p&gt;I can't promise that. The best-known controlled study found a slowdown in 2025 and an inconclusive result in 2026. Measure your own review time and defect rate over a few weeks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/ai-coding-prompts-improve-developer-productivity" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aicoding</category>
      <category>prompts</category>
      <category>productivity</category>
      <category>codereview</category>
    </item>
    <item>
      <title>Claude Code vs Cursor vs Copilot: Which Is Best in 2026?</title>
      <dc:creator>devTalk</dc:creator>
      <pubDate>Mon, 09 Nov 2026 02:46:19 +0000</pubDate>
      <link>https://dev.to/devtalk94/claude-code-vs-cursor-vs-copilot-which-is-best-in-2026-b15</link>
      <guid>https://dev.to/devtalk94/claude-code-vs-cursor-vs-copilot-which-is-best-in-2026-b15</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 5 November 2026. Pricing and plans for all three tools changed several times this year, so see the&lt;/em&gt;&lt;em&gt;update log&lt;/em&gt;&lt;em&gt;at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Asking which of these three is "best" runs into a problem before you compare a single feature: they are not the same kind of product. Claude Code is an agent that works in your terminal. Cursor is a code editor built around AI. GitHub Copilot is an assistant that plugs into the editors you already use and into GitHub itself. Picking the wrong one usually means picking the wrong &lt;em&gt;shape&lt;/em&gt; of tool for how you work. This article compares them on that basis, shows the commands for the two that have a CLI, and gives you a way to test all three on your own code in an afternoon, because nobody else's benchmark runs on your repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;Cursor&lt;/th&gt;
&lt;th&gt;GitHub Copilot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Terminal-first coding agent&lt;/td&gt;
&lt;td&gt;AI-native code editor&lt;/td&gt;
&lt;td&gt;Assistant for many editors, plus GitHub integration and a CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where you use it&lt;/td&gt;
&lt;td&gt;Terminal (IDE integrations exist)&lt;/td&gt;
&lt;td&gt;The Cursor editor&lt;/td&gt;
&lt;td&gt;VS Code, other IDEs, GitHub, terminal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing model&lt;/td&gt;
&lt;td&gt;Included in Claude Pro and Max plans, or pay-as-you-go API&lt;/td&gt;
&lt;td&gt;Subscription with a monthly dollar usage pool&lt;/td&gt;
&lt;td&gt;Subscription with monthly AI credits since June 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Multi-file tasks, scripts, CI, people who live in the terminal&lt;/td&gt;
&lt;td&gt;People who want the AI built into the editor itself&lt;/td&gt;
&lt;td&gt;Teams already on GitHub and mixed editors; cheapest entry point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watch out for&lt;/td&gt;
&lt;td&gt;Usage limits on subscription plans&lt;/td&gt;
&lt;td&gt;Usage pool running out on heavy agent use&lt;/td&gt;
&lt;td&gt;Credits drawn down by agent and premium-model use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want one sentence: pick the one that matches where you already work, then test it on real tasks. Many developers use more than one, as the next section shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What developers actually use
&lt;/h2&gt;

&lt;p&gt;In the Stack Overflow Developer Survey 2026, among respondents who use AI coding assistants or agents (about 12,000 answers), the most used agents were:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Share of those respondents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;65.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;58.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Codex&lt;/td&gt;
&lt;td&gt;29.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;25.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two cautions. Survey respondents choose to take the survey, so this is not a random sample of developers. And the shares add up to far more than 100%, which means many people use two or more tools. It is evidence of popularity, not of quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing as of November 2026
&lt;/h2&gt;

&lt;p&gt;I could not open the vendors' own pricing pages from my environment, so the figures below are cross-checked from several independent roundups that agreed with each other, dated between July and November 2026. Treat them as a guide and confirm on the official pages before you pay.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Monthly price&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$20 ($17 billed annually)&lt;/td&gt;
&lt;td&gt;Claude Code is included; the Free plan does not include it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max 5x&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;td&gt;About 5 times Pro usage&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max 20x&lt;/td&gt;
&lt;td&gt;$200&lt;/td&gt;
&lt;td&gt;About 20 times Pro usage&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;Pay per token&lt;/td&gt;
&lt;td&gt;No monthly minimum; usage-based&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;Hobby&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Limited agent requests and tab completions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;Monthly usage pool in dollars&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro+&lt;/td&gt;
&lt;td&gt;$60&lt;/td&gt;
&lt;td&gt;Larger pool, same features as Pro&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ultra&lt;/td&gt;
&lt;td&gt;$200&lt;/td&gt;
&lt;td&gt;Largest individual pool&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teams&lt;/td&gt;
&lt;td&gt;$40 per user&lt;/td&gt;
&lt;td&gt;Adds SSO, analytics, central billing&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;Limited monthly allowance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$10&lt;/td&gt;
&lt;td&gt;Includes monthly AI credits&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro+&lt;/td&gt;
&lt;td&gt;$39&lt;/td&gt;
&lt;td&gt;More credits, access to all models&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;td&gt;Highest individual credit allowance&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;$19 per seat&lt;/td&gt;
&lt;td&gt;Org policy, audit logs, SSO&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;$39 per seat&lt;/td&gt;
&lt;td&gt;Adds knowledge bases on private code&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The prices matter less than how each tool counts usage, because that is what surprises people later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude plans&lt;/strong&gt; limit usage in rolling five-hour windows plus a weekly limit, shared between Claude's apps and Claude Code. Anthropic publishes multipliers, not token counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor&lt;/strong&gt; uses dollar-denominated monthly pools. When the pool runs out, on-demand usage is billed after the fact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copilot&lt;/strong&gt; moved to usage-based billing on 1 June 2026: each plan includes AI credits, and agent and premium-model use draws them down. Roundups report that plain code completions do not use credits. One roundup also noted that sign-ups for some individual plans were temporarily paused in mid-2026, so check that your plan is available.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Claude Code: working examples
&lt;/h2&gt;

&lt;p&gt;Claude Code is the one to look at if you want scripting. The &lt;code&gt;-p&lt;/code&gt; flag runs a single prompt non-interactively, so it works in pipes and CI. I ran these with Claude Code 2.1.318:&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;# Pipe a failing test into it&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FAILED Tests\\Feature\\InvoiceTest::test_export - Expected 200 but received 419\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"In one short sentence, what does this test output suggest?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A 419 in Laravel means the CSRF token was missing or expired, so the test's export
request (likely a POST) is being rejected before it reaches the controller. ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Machine-readable output&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"say ok"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; json     &lt;span class="c"&gt;# the answer is in the "result" field&lt;/span&gt;

&lt;span class="c"&gt;# Continue the most recent conversation in this directory&lt;/span&gt;
claude &lt;span class="nt"&gt;-c&lt;/span&gt;

&lt;span class="c"&gt;# Cap spending on a scripted run&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Review the staged changes"&lt;/span&gt; &lt;span class="nt"&gt;--max-budget-usd&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output formats are &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt; and &lt;code&gt;stream-json&lt;/code&gt;. I did not run the &lt;code&gt;--max-budget-usd&lt;/code&gt; example; it comes from the CLI's help text. For the interactive commands, see &lt;a href="https://dev-talk.com/claude-code-commands-every-developer-should-know" rel="noopener noreferrer"&gt;10 Claude Code commands every developer should know&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Copilot CLI: working examples
&lt;/h2&gt;

&lt;p&gt;Copilot also has a terminal agent, installed from npm. I installed version 1.0.105 and read its options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @github/copilot
copilot &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Copilot CLI 1.0.105.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Explain what this repository does"&lt;/span&gt;     &lt;span class="c"&gt;# one prompt, then exit&lt;/span&gt;
copilot &lt;span class="nt"&gt;--continue&lt;/span&gt;                                  &lt;span class="c"&gt;# continue the last session&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt; &lt;span class="nt"&gt;--deny-tool&lt;/span&gt; ...                    &lt;span class="c"&gt;# restrict which tools it may use&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The help lists &lt;code&gt;-p&lt;/code&gt;/&lt;code&gt;--prompt&lt;/code&gt;, &lt;code&gt;--model&lt;/code&gt;, &lt;code&gt;--continue&lt;/code&gt;, &lt;code&gt;--allow-tool&lt;/code&gt;, &lt;code&gt;--deny-tool&lt;/code&gt; and &lt;code&gt;--output-format&lt;/code&gt;, plus &lt;code&gt;--allow-all&lt;/code&gt; (also &lt;code&gt;--yolo&lt;/code&gt;), which switches off permission prompts. I could not log in from my environment, so I did not run an actual prompt. Prefer the narrower &lt;code&gt;--allow-tool&lt;/code&gt; and &lt;code&gt;--deny-tool&lt;/code&gt; options over &lt;code&gt;--allow-all&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cursor
&lt;/h2&gt;

&lt;p&gt;Cursor is an editor, so most of what it does happens in its interface (tab completion, an agent panel, inline edits) and does not translate into terminal commands. I did not install or run it, so I cannot show output for it, and I am not going to describe its features from marketing copy. The facts I can support are its plans and its usage pools, in the pricing section above. If you try it, the free Hobby plan includes a one-week Pro trial according to the roundups, which is enough for the test below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test all three on your own code
&lt;/h2&gt;

&lt;p&gt;I did not run a head-to-head benchmark, and I would distrust any article that claims one without publishing the tasks. Run your own in an afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick three real tasks from your backlog: a small bug fix, a feature that touches several files, and a refactor with tests.&lt;/li&gt;
&lt;li&gt;Use the same prompt for each tool, starting from the same git branch.&lt;/li&gt;
&lt;li&gt;Record: did the tests pass, how large was the diff, how long did you spend reviewing and fixing, and what did it cost in usage.&lt;/li&gt;
&lt;li&gt;Compare on &lt;strong&gt;review time&lt;/strong&gt;, not on how impressive the demo looked. The slow part of AI-assisted work is reading the result.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch &lt;span class="nt"&gt;-c&lt;/span&gt; try-claude main
&lt;span class="c"&gt;# ... run the task, then record the numbers&lt;/span&gt;
git diff &lt;span class="nt"&gt;--stat&lt;/span&gt; main
php artisan &lt;span class="nb"&gt;test&lt;/span&gt;        &lt;span class="c"&gt;# or your own test command&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which one should you choose?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You work mostly in the terminal, or want to automate tasks in scripts and CI:&lt;/strong&gt; Claude Code or Copilot CLI. Claude Code is the one I tested end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want AI inside the editor on every keystroke:&lt;/strong&gt; Cursor is built for that, and Copilot is the alternative if you want to keep VS Code or a JetBrains IDE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your company is on GitHub and needs policy controls, audit logs and one invoice:&lt;/strong&gt; Copilot Business or Enterprise fits that setup, because it lives where your repositories already are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget is tight:&lt;/strong&gt; Copilot Pro at $10 is the lowest-priced paid individual plan in the table, and all three have free tiers to test with. Compare usage limits, not only prices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You use Laravel:&lt;/strong&gt; any of them can work. Laravel Boost, the official package that gives coding agents project context, supports a dozen agents, including all three. See &lt;a href="https://dev-talk.com/ai-agents-build-laravel-applications" rel="noopener noreferrer"&gt;How to use AI agents to build Laravel applications&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choosing by demo.&lt;/strong&gt; Short demos hide the review cost. Judge by the three-task test above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring how usage is counted.&lt;/strong&gt; A cheap plan with a small pool feels fine for a week and then runs out mid-task. Check the usage model, not only the price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switching off permission prompts.&lt;/strong&gt; Options like &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; in Claude Code or &lt;code&gt;--allow-all&lt;/code&gt; in Copilot CLI remove the checks that stop a bad command. Use them only in disposable sandboxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pasting secrets.&lt;/strong&gt; Keep &lt;code&gt;.env&lt;/code&gt; contents out of prompts with every tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not reading the generated tests.&lt;/strong&gt; A tool that writes the code and the test can make the same mistake in both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming you must pick one.&lt;/strong&gt; Mixing is normal, for example Copilot completions in the editor and a terminal agent for larger tasks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alternatives
&lt;/h2&gt;

&lt;p&gt;The survey data above also lists OpenAI Codex (29.5%), Google Antigravity (16.0%) and Gemini Code Assist (14.6%). JetBrains users can look at Junie. I have not tested any of these for this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Dev Talk articles and tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/claude-code-commands-every-developer-should-know" rel="noopener noreferrer"&gt;10 Claude Code commands every developer should know&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/ai-agents-build-laravel-applications" rel="noopener noreferrer"&gt;How to use AI agents to build Laravel applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/laravel-ai-sdk-agent-skills" rel="noopener noreferrer"&gt;Laravel AI SDK: building agents inside your app&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/what-developers-search-and-use-2026" rel="noopener noreferrer"&gt;What developers search for and use in 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev-talk.com/open-source-tools-developer-productivity" rel="noopener noreferrer"&gt;Open source tools that make developers more productive&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools worth a look alongside these: &lt;a href="https://github.com/laravel/boost" rel="noopener noreferrer"&gt;Laravel Boost&lt;/a&gt; for PHP projects, and your existing test runner and formatter (Pest or PHPUnit, Pint), since an agent with a fast feedback loop produces better results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which is the cheapest?
&lt;/h3&gt;

&lt;p&gt;By list price, Copilot Pro at $10 per month. But all three meter agent usage, so the real cost depends on how much you use the agent features. Test with the free tiers first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Claude Code free?
&lt;/h3&gt;

&lt;p&gt;No. It needs a paid Claude plan (Pro or higher) or API credits. The Free Claude plan does not include it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use more than one?
&lt;/h3&gt;

&lt;p&gt;Yes, and many developers do. They can coexist in the same project, since they work on the same files and git history. Review changes from each before committing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which one is best for beginners?
&lt;/h3&gt;

&lt;p&gt;An in-editor tool (Copilot or Cursor) is usually the gentler start, because suggestions appear where you are already typing. A terminal agent asks you to be comfortable with the command line and git.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will my code be used for training?
&lt;/h3&gt;

&lt;p&gt;Policies differ by vendor and plan, and they change. I did not verify them for this article. Read each vendor's current data-use terms, especially on free and individual plans, before using private code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://dev-talk.com/post/claude-code-vs-cursor-vs-github-copilot" rel="noopener noreferrer"&gt;DEV Talk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>cursor</category>
      <category>githubcopilot</category>
      <category>aicoding</category>
    </item>
  </channel>
</rss>
