<?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: Elias Alrgeai</title>
    <description>The latest articles on DEV Community by Elias Alrgeai (@eliasalrgeaidev).</description>
    <link>https://dev.to/eliasalrgeaidev</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%2F3990675%2F2323319d-a2db-4506-80dc-8b4eea989d9b.png</url>
      <title>DEV Community: Elias Alrgeai</title>
      <link>https://dev.to/eliasalrgeaidev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eliasalrgeaidev"/>
    <language>en</language>
    <item>
      <title>The Hidden Race Condition Inside firstOrCreate()</title>
      <dc:creator>Elias Alrgeai</dc:creator>
      <pubDate>Sat, 12 Sep 2026 05:37:00 +0000</pubDate>
      <link>https://dev.to/eliasalrgeaidev/the-hidden-race-condition-inside-firstorcreate-24pc</link>
      <guid>https://dev.to/eliasalrgeaidev/the-hidden-race-condition-inside-firstorcreate-24pc</guid>
      <description>&lt;p&gt;A user accidentally double clicks 'add to cart' before the button disables, firing 2 requests to your server. The requests arrive within milliseconds of each other, both calling &lt;code&gt;Cart::firstOrCreate(['user_id' =&amp;gt; $userId])&lt;/code&gt; for the same customer.&lt;/p&gt;

&lt;p&gt;Both requests check to see if a cart exists, and since neither one has created it yet, neither finds a cart, and both requests proceed to create one, resulting in two for the same user.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;massive&lt;/strong&gt; deal, since it could result in breaking the user experience or even double charging.&lt;/p&gt;

&lt;h2&gt;
  
  
  How &lt;code&gt;firstOrCreate()&lt;/code&gt; Can Create a Race Condition
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;firstOrCreate()&lt;/code&gt; seems like an atomic process, but it isn't. Behind the scenes, calling &lt;code&gt;firstOrCreate()&lt;/code&gt; runs two separate queries, with a gap between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;carts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- if nothing found:&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;carts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is how that gap causes a race condition:&lt;/p&gt;

&lt;p&gt;Let's say Request A arrives milliseconds before Request B, and runs the &lt;code&gt;SELECT&lt;/code&gt; query first. Request A finds nothing that matches the &lt;code&gt;SELECT&lt;/code&gt;. Right before Request A fires its &lt;code&gt;INSERT&lt;/code&gt; query, Request B runs its &lt;code&gt;SELECT&lt;/code&gt; query, finding nothing either. Now, both requests send an &lt;code&gt;INSERT&lt;/code&gt; query, creating duplicate carts. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Approach
&lt;/h2&gt;

&lt;p&gt;A common assumption is that wrapping &lt;code&gt;firstOrCreate()&lt;/code&gt; inside of &lt;code&gt;DB::transaction()&lt;/code&gt; makes it atomic:&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="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&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;Cart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;firstOrCreate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&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;However, this doesn't actually fix anything. &lt;code&gt;DB::transaction()&lt;/code&gt; only creates an atomic environment for requests nested &lt;strong&gt;inside&lt;/strong&gt; of it, and has zero control over a completely separate request out of its scope. Request A and Request B don't know each other, and therefore they can't be wrapped together in a transaction. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Correct Fix
&lt;/h2&gt;

&lt;p&gt;The correct fix to this issue isn't relying on more application code. Instead, it's enforcing a database-level unique constraint on the column being checked:&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;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, if two requests both attempt the same &lt;code&gt;INSERT&lt;/code&gt; query, the database will reject the duplicate creation attempt, making it impossible for more than one of the queries to succeed. To prevent an unhandled exception, the database rejection must be managed:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;QueryException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&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;first&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;Implementing a unique constraint makes it structurally impossible for duplicates to occur, since it is forced at the database level where the data exists, not on the application side. &lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;firstOrCreate()&lt;/code&gt; seems like a single operation, but behind the scenes, it's a &lt;code&gt;SELECT&lt;/code&gt; query followed by an &lt;code&gt;INSERT&lt;/code&gt; query, with a gap between them. If two identical requests arrive within milliseconds of each other, there is a genuine chance that they both fall through, causing duplicate rows in the database.&lt;/p&gt;

&lt;p&gt;Wrapping &lt;code&gt;firstOrCreate()&lt;/code&gt; inside of the &lt;code&gt;DB::transaction()&lt;/code&gt; method doesn't actually fix anything, since &lt;code&gt;DB::transaction()&lt;/code&gt; only creates an atomic environment for requests nested inside of it, not completely separate requests it doesn't even know exist. &lt;/p&gt;

&lt;p&gt;The correct fix is enforcing a unique constraint at the database level, which makes it structurally impossible for duplicates to happen no matter what. &lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>backend</category>
      <category>database</category>
    </item>
    <item>
      <title>The Soft Delete Bug That Lets Duplicate Accounts Slip Through (And the Fix in Laravel)</title>
      <dc:creator>Elias Alrgeai</dc:creator>
      <pubDate>Sun, 06 Sep 2026 20:30:51 +0000</pubDate>
      <link>https://dev.to/eliasalrgeaidev/the-soft-delete-bug-that-lets-duplicate-accounts-slip-through-and-the-fix-in-laravel-24fm</link>
      <guid>https://dev.to/eliasalrgeaidev/the-soft-delete-bug-that-lets-duplicate-accounts-slip-through-and-the-fix-in-laravel-24fm</guid>
      <description>&lt;p&gt;A client creates an account using the email &lt;code&gt;elias@example.com&lt;/code&gt;. Later, they delete the account. A few weeks later, they register again, using the same email they used in their former account. &lt;/p&gt;

&lt;p&gt;The registration fails with 'email already taken'.&lt;/p&gt;

&lt;p&gt;Why? Because Laravel's soft delete doesn't literally remove the row from the database. It is only a filter on Laravel's side. To your database, the email is still a duplicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Soft Delete Actually Does
&lt;/h2&gt;

&lt;p&gt;When a model uses the &lt;code&gt;SoftDeletes&lt;/code&gt; trait, &lt;code&gt;delete()&lt;/code&gt; doesn't actually run a &lt;code&gt;DELETE&lt;/code&gt; query. Instead, it runs an &lt;code&gt;UPDATE&lt;/code&gt; query, changing the &lt;code&gt;deleted_at&lt;/code&gt; column to the timestamp it was deleted at. The rest of the row remains completely intact. &lt;/p&gt;

&lt;p&gt;Behind the scenes, Laravel adds &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; to queries such as &lt;code&gt;User::find()&lt;/code&gt; or &lt;code&gt;User::all()&lt;/code&gt;, filtering out soft deleted rows. The rows aren't gone, they only get filtered out. Using a command such as &lt;code&gt;User::withTrashed()&lt;/code&gt; can still retrieve them. &lt;/p&gt;

&lt;p&gt;The database doesn't know 'soft deleted'. It only sees &lt;code&gt;deleted_at&lt;/code&gt; with a timestamp. &lt;/p&gt;

&lt;h2&gt;
  
  
  Where the unique constraint actually lives
&lt;/h2&gt;

&lt;p&gt;A unique constraint on &lt;code&gt;email&lt;/code&gt; isn't enforced by Laravel. Instead, it's enforced by the database. When a new row is inserted, the DB checks: 'does any existing row in this table have the same email?' &lt;/p&gt;

&lt;p&gt;The check is never enforced by Laravel, and the DB isn't aware that &lt;code&gt;deleted_at&lt;/code&gt; with a timestamp means soft deleted, so every single row is checked, including soft deleted ones. If a soft deleted row has the same email as the new row, it will reject it. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Heavily Flawed Naive Approach
&lt;/h2&gt;

&lt;p&gt;The logical fix seems to just compare both &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;deleted_at&lt;/code&gt;, making sure they aren't identical. However, this actually introduces a &lt;strong&gt;bigger&lt;/strong&gt; issue.&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;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deleted_at'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You see, in standard SQL, &lt;code&gt;NULL&lt;/code&gt; is never equal to another &lt;code&gt;NULL&lt;/code&gt;, even inside a unique index. Rows that aren't soft deleted have &lt;code&gt;deleted_at&lt;/code&gt; set to &lt;code&gt;NULL&lt;/code&gt; by default. This means when the database compares the two 'identical' rows, it will see that both rows have &lt;code&gt;deleted_at&lt;/code&gt; set to &lt;code&gt;NULL&lt;/code&gt;, and since &lt;code&gt;NULL&lt;/code&gt; ≠ &lt;code&gt;NULL&lt;/code&gt;, it will allow the new account's creation, even if they are duplicates. This not only fails at fixing the main issue, but now allows for the creation of duplicate accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Correct Implementation
&lt;/h2&gt;

&lt;p&gt;Never rely on &lt;code&gt;deleted_at&lt;/code&gt; being &lt;code&gt;NULL&lt;/code&gt; for active rows. Instead, add a column called &lt;code&gt;deleted_at_unix&lt;/code&gt;, set its default to &lt;code&gt;0&lt;/code&gt;, and create a composite unique constraint spanning both &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;deleted_at_unix&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;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsignedInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'deleted_at_unix'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&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;'deleted_at_unix'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rows that have &lt;code&gt;deleted_at_unix&lt;/code&gt; set to &lt;code&gt;0&lt;/code&gt; are active, while soft deleted rows should have the deletion timestamp stored. &lt;/p&gt;

&lt;p&gt;Make sure &lt;code&gt;deleted_at_unix&lt;/code&gt; is always set correctly by hooking it straight into the model's &lt;code&gt;deleting&lt;/code&gt; event:&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;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;booted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deleting&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="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deleted_at_unix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;saveQuietly&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;Since &lt;code&gt;0&lt;/code&gt; is a real comparable value, MySQL would evaluate any two rows that have &lt;code&gt;deleted_at_unix&lt;/code&gt; set to &lt;code&gt;0&lt;/code&gt; as duplicates, and it won't allow the creation of the new row. A soft-deleted row has &lt;code&gt;deleted_at_unix&lt;/code&gt; set to the row's deletion timestamp instead of &lt;code&gt;0&lt;/code&gt;, so a new signup that shares the same email as the soft-deleted row succeeds. Multiple soft-deleted rows sharing the same email also don't cause any issues since they have unique timestamps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Not Just Default &lt;code&gt;deleted_at&lt;/code&gt; to &lt;code&gt;0&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Laravel's &lt;code&gt;SoftDeletes&lt;/code&gt; trait identifies active rows as the ones with &lt;code&gt;deleted_at&lt;/code&gt; set to &lt;code&gt;NULL&lt;/code&gt;, so if anything else is stored, including &lt;code&gt;0&lt;/code&gt;, the row will be recognized as soft deleted. &lt;/p&gt;

&lt;p&gt;This is why a separate &lt;code&gt;deleted_at_unix&lt;/code&gt; column is used, to keep Laravel's &lt;code&gt;SoftDeletes&lt;/code&gt; behaving as it should, while &lt;code&gt;deleted_at_unix&lt;/code&gt; enforces a database level unique constraint to prevent duplicate active accounts from being created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In other words, soft-delete is a filter that hides rows from Laravel's database queries. The row still exists and takes up storage in the database, and a unique constraint on a column such as &lt;code&gt;email&lt;/code&gt; checks every single row, including 'soft-deleted' ones that have a timestamp on &lt;code&gt;deleted_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It seems the obvious fix is to create a composite unique constraint across &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;deleted_at&lt;/code&gt;, however, this introduces a bigger issue. Active rows have &lt;code&gt;deleted_at&lt;/code&gt; set to &lt;code&gt;NULL&lt;/code&gt; by default, and to MySQL, &lt;code&gt;NULL&lt;/code&gt; is never equal to another &lt;code&gt;NULL&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;The correct implementation is to add a column called &lt;code&gt;deleted_at_unix&lt;/code&gt; defaulted to &lt;code&gt;0&lt;/code&gt;, and setting a composite unique constraint spanning &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;deleted_at_unix&lt;/code&gt;. When the row is deleted, the value of &lt;code&gt;deleted_at_unix&lt;/code&gt; gets updated from &lt;code&gt;0&lt;/code&gt; to the deletion timestamp.&lt;/p&gt;

&lt;p&gt;Integrating this approach correctly ensures new accounts cannot get rejected for sharing the same email as a deleted account, and for duplicate accounts to never slip through.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>database</category>
      <category>backend</category>
    </item>
    <item>
      <title>The Webhook Bug That Gives Cancelled Users Free Access</title>
      <dc:creator>Elias Alrgeai</dc:creator>
      <pubDate>Tue, 01 Sep 2026 10:01:48 +0000</pubDate>
      <link>https://dev.to/eliasalrgeaidev/the-webhook-bug-that-gives-cancelled-users-free-access-46c7</link>
      <guid>https://dev.to/eliasalrgeaidev/the-webhook-bug-that-gives-cancelled-users-free-access-46c7</guid>
      <description>&lt;p&gt;A client pays for a subscription, then seconds later they decide to cancel it. Your payment provider sends two webhooks: "payment successful" then "subscription cancelled". &lt;/p&gt;

&lt;p&gt;However, your server receives the webhooks in the opposite order, giving free unpaid access to the user. &lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Happens
&lt;/h2&gt;

&lt;p&gt;Webhooks aren't sent as a single sequence. They are sent separately, traveling at independent speeds and asynchronous times. This means one of the webhooks may get delayed without affecting the other one.&lt;/p&gt;

&lt;p&gt;Delays can happen for several reasons such as a network congestion, server load, or the most common: a retry after a failure to respond.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Approach and Why It Fails
&lt;/h2&gt;

&lt;p&gt;A common approach of handling webhooks is by processing them on arrival, updating the DB as soon as possible. This method has a &lt;strong&gt;fatal&lt;/strong&gt; flaw.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&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;$event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'payment_succeeded'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&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="s1"&gt;'active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'subscription_cancelled'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&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="s1"&gt;'active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This naive implementation only works if the webhooks arrive in order. But if 'subscription cancelled' arrives first, it sets &lt;code&gt;active&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;, then when 'payment successful' arrives late, &lt;code&gt;active&lt;/code&gt; is changed to &lt;code&gt;true&lt;/code&gt;, giving free access to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Correct Implementation
&lt;/h2&gt;

&lt;p&gt;A webhook should only be processed if its actually newer than the last one. &lt;/p&gt;

&lt;p&gt;Webhook payloads include a timestamp of when the event actually occurred. The timestamp should be checked before applying any update and stored along with the user's status.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$eventTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'timestamp'&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="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&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;$eventTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;last_event_timestamp&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="c1"&gt;// older event, ignore it&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;$event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'payment_succeeded'&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;-&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="s1"&gt;'active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'last_event_timestamp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$eventTime&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;$event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'subscription_cancelled'&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;-&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="s1"&gt;'active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'last_event_timestamp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$eventTime&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, even if 'payment successful' arrives late, it will be rejected before any update can be applied since its timestamp is older than the previous 'subscription cancelled' webhook. &lt;/p&gt;

&lt;h2&gt;
  
  
  Storing Raw Events First
&lt;/h2&gt;

&lt;p&gt;For extra safety, it's critical to save every incoming webhook to the database immediately, no matter how old the webhook is or whether it was rejected by the timestamp check or not. &lt;/p&gt;

&lt;p&gt;This preserves a permanent record of every event exactly the way it was received, so if anything goes wrong, the events are all preserved, untouched, and can be replayed safely at any time. Saving the webhook should happen before the webhook gets processed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Webhooks aren't guaranteed to arrive in the correct order. Processing them on arrival holds the potential of giving the user free, unpaid access.&lt;/p&gt;

&lt;p&gt;To prevent handing out free subscriptions, reject any webhook that arrives with a timestamp older than the previous webhook, and make sure to first store the webhook's response as-is in the database just in case you need the record later.&lt;/p&gt;

&lt;p&gt;Integrating this system correctly ensures you don't have to worry about webhooks arriving late ever again. &lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>backend</category>
      <category>stripe</category>
    </item>
    <item>
      <title>How a Timeout Can Charge Your Customer Twice (And How to Stop It in Laravel)</title>
      <dc:creator>Elias Alrgeai</dc:creator>
      <pubDate>Sat, 29 Aug 2026 04:45:30 +0000</pubDate>
      <link>https://dev.to/eliasalrgeaidev/how-a-timeout-can-charge-your-customer-twice-and-how-to-stop-it-in-laravel-1f9a</link>
      <guid>https://dev.to/eliasalrgeaidev/how-a-timeout-can-charge-your-customer-twice-and-how-to-stop-it-in-laravel-1f9a</guid>
      <description>&lt;p&gt;A customer attempts a payment. The request safely reaches your server, and the transaction is performed successfully. Then, while the response is being sent back to the client, the connection drops. The client never receives a success message. Instead, they see a timeout. &lt;/p&gt;

&lt;p&gt;The client has no possible way of knowing if the charge went through or not, so they logically retry. This is where problems happen. Your server receives a second &lt;code&gt;POST&lt;/code&gt; request for attempting the same payment, with no idea if it was successfully charged before or not.&lt;/p&gt;

&lt;p&gt;Without correctly handling this scenario, the client gets charged twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an Idempotency Key is
&lt;/h2&gt;

&lt;p&gt;In this context, an idempotency key, usually a UUID, represents a specific payment intent. The client generates the idempotency key, stores it, and sends it along with the request to the server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Idempotency-Key: 8f14e45f-ceea-4e97-9e39-1b2f3a4c5d6e
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is sent as a header on the request. If the client re-attempts the payment due to a timeout, it never creates a new one, it instead uses the same key. This way, the server can verify if a transaction has been processed before or not. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Approach and Why it Fails
&lt;/h2&gt;

&lt;p&gt;Here is an example of a naive way of handling this scenario:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&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;exists&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;processCharge&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="nc"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'response'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This implementation may look correct, but it has a massive &lt;strong&gt;flaw&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A server can handle multiple requests simultaneously. If two requests arrive around the same time, which is more common than expected, both could run &lt;code&gt;exists()&lt;/code&gt; before either one gets a chance to save the key. This results in both requests being processed at the same time, double charging the customer. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Atomic Locking
&lt;/h2&gt;

&lt;p&gt;The check and save must happen as one process, not two separate steps. That's what Laravel's &lt;code&gt;Cache::lock()&lt;/code&gt; is for.&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;// Block for 5 seconds to let the first request finish processing, then reuse its result&lt;/span&gt;
&lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'idempotency:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$key&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;block&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&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;$existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&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;first&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;$existing&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;$existing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;processCharge&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="nc"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'response'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$result&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;$result&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;Unlike in the naive implementation, &lt;code&gt;Cache::lock()&lt;/code&gt; is used to ensure multiple requests cannot be processed simultaneously. Even if the difference is in milliseconds, whichever request comes first gets the lock, and the second request cannot be processed until the first one is complete, making double charging completely impossible. &lt;/p&gt;

&lt;h2&gt;
  
  
  Storing the Response, Not Just the Key
&lt;/h2&gt;

&lt;p&gt;In the correct approach, notice that the response is stored along with the key. This is very critical because if the client retries, we want to give them the exact same response that was supposed to be sent back in the original request. From the client's view, the retry looks like a normal, successful request. &lt;/p&gt;

&lt;h2&gt;
  
  
  Expiration
&lt;/h2&gt;

&lt;p&gt;Idempotency keys should never be stored forever. Once the retry window has passed, the key should be deleted to prevent unnecessary storage cluttering your database.&lt;/p&gt;

&lt;p&gt;The majority of implementations store a TTL (time-to-live) with each key. For example, Stripe uses a TTL of 24 hours. Long enough for realistic retry scenarios, but short enough to ensure old keys don't pile up permanently. &lt;/p&gt;

&lt;p&gt;A great way of implementing this system in Laravel is by using a scheduled job that scans the DB for old idempotency keys that aren't needed anymore and deletes them. &lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Idempotency keys exist to tell the server if a transaction has already been processed before or not. However, that isn't enough. Two requests can arrive at approximately the same time, double charging a client. &lt;/p&gt;

&lt;p&gt;The check and save must happen atomically (locking out simultaneous requests), using a method such as &lt;code&gt;Cache::lock()&lt;/code&gt;. It is also crucial to store the response along with the idempotency key, so when the client retries, they receive the same response they would have received with the original request. &lt;/p&gt;

&lt;p&gt;Integrating this system correctly ensures that a timeout never leaves you guessing whether a transaction was successful or not.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>backend</category>
      <category>stripe</category>
    </item>
    <item>
      <title>chunk() vs cursor() vs lazy() — I Benchmarked All 3 So You Don't Have To</title>
      <dc:creator>Elias Alrgeai</dc:creator>
      <pubDate>Sat, 22 Aug 2026 10:28:02 +0000</pubDate>
      <link>https://dev.to/eliasalrgeaidev/chunk-vs-cursor-vs-lazy-i-benchmarked-all-3-so-you-dont-have-to-346p</link>
      <guid>https://dev.to/eliasalrgeaidev/chunk-vs-cursor-vs-lazy-i-benchmarked-all-3-so-you-dont-have-to-346p</guid>
      <description>&lt;p&gt;I never truly understood the difference between &lt;code&gt;chunk()&lt;/code&gt;, &lt;code&gt;cursor()&lt;/code&gt;, and &lt;code&gt;lazy()&lt;/code&gt;, so I ran a quick test: I seeded 100,000 rows into a table, then looped through all of them 3 times. Once with &lt;code&gt;chunk()&lt;/code&gt;, once with &lt;code&gt;cursor()&lt;/code&gt;, and once with &lt;code&gt;lazy()&lt;/code&gt;, measuring memory usage, query count, and execution time for each one.&lt;/p&gt;

&lt;p&gt;Here's what I learned from this test, and why the difference between these three isn't just syntax. It's a real architectural decision that affects your database connections and your app's stability under load. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Problem
&lt;/h2&gt;

&lt;p&gt;Here is what happens when you run something such as &lt;code&gt;User::all()&lt;/code&gt; or &lt;code&gt;User::get()&lt;/code&gt; on a big table: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The database runs the query&lt;/strong&gt;: &lt;code&gt;SELECT * FROM users&lt;/code&gt;, and hands back every matching row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP turns each row into an Eloquent model&lt;/strong&gt;: a full object, sitting in memory, for every single row (this is where the issues happen).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The database doesn't struggle with this. MySQL or Postgres can have millions of rows and still be fine. The issue happens in step 2, when PHP attempts to hold every single one of those rows as an object all at once in RAM. &lt;/p&gt;

&lt;p&gt;A single hydrated Eloquent model takes up roughly 5 KB to 10 KB of RAM. On a small app with 1000 users, holding every single row from a table as an object in memory isn't an issue. But on an app with 10,000+ users, RAM usage can easily blow past 128 MB, causing a fatal error crashing the entire operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 32768 bytes) in /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php on line 512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Here is a diagram visualizing the issue:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8u1y9ycyaydzskr3zdmz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8u1y9ycyaydzskr3zdmz.png" alt=" " width="800" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This issue is exactly why &lt;code&gt;chunk()&lt;/code&gt;, &lt;code&gt;cursor()&lt;/code&gt;, and &lt;code&gt;lazy()&lt;/code&gt; exist. The 3 functions are solutions to this problem, however they have different methods of solving it, and the difference matters significantly. &lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;chunk()&lt;/code&gt; - Multiple Small Trips
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;chunk()&lt;/code&gt; works by breaking one big query into smaller ones, so your server doesn't have to hold all the rows in memory at once. Instead of hydrating all the rows at once and filling up the server's memory, it performs PHP hydration multiple times, each time with a different group of rows.&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="nc"&gt;User&lt;/span&gt;&lt;span class="o"&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;200&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="nv"&gt;$users&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;$users&lt;/span&gt; &lt;span class="k"&gt;as&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="c1"&gt;// process each user&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;Behind the scenes, Laravel actually runs something like &lt;code&gt;SELECT * FROM users LIMIT 200 OFFSET 0&lt;/code&gt;, processes that batch, throws it away, then runs &lt;code&gt;LIMIT 200 OFFSET 200&lt;/code&gt;. It keeps repeating the process like a loop, until all of the rows are hydrated.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Twist
&lt;/h3&gt;

&lt;p&gt;When deleting or updating rows while looping, the &lt;code&gt;OFFSET&lt;/code&gt; shifts (let's say you're deleting 200 rows at a time, the first 200 rows get deleted, now &lt;code&gt;index 0&lt;/code&gt; has shifted). This causes some rows to get skipped and others processed twice. &lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix: &lt;code&gt;chunkById()&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;chunkById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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="nv"&gt;$users&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 even if you delete rows inside the loop&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of paginating by offset (get the next 200 rows), &lt;code&gt;chunkById()&lt;/code&gt; paginates by key (get rows where &lt;code&gt;id &amp;gt; last id processed&lt;/code&gt;). This way, if rows get updated or deleted, it doesn't affect the process, since it's based on the row's actual identity. &lt;/p&gt;

&lt;p&gt;Here is a clear side-by-side comparison between &lt;code&gt;chunk()&lt;/code&gt; and &lt;code&gt;chunkById()&lt;/code&gt; visualizing what happens when a record gets deleted:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyst51ue3elp7hqft3wy9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyst51ue3elp7hqft3wy9.png" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;cursor()&lt;/code&gt; - One Row at a Time
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// process each user&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;cursor()&lt;/code&gt; works like streaming a YouTube video. It sends one query to the database requesting all of the data, but gets each row one at a time, processes it, discards it, then moves to the next one. This way, &lt;code&gt;cursor()&lt;/code&gt; uses up virtually zero memory from your server, no matter how significant the amount of data is. It sounds amazing at first, however, understanding when &lt;code&gt;cursor()&lt;/code&gt; should not be used and when it can be useful is very critical.&lt;/p&gt;

&lt;h3&gt;
  
  
  When NOT to Use &lt;code&gt;cursor()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Since it's one query for the entire dataset, the database connection remains busy. This matters a lot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Slow work inside the loop&lt;/strong&gt;: If you're doing heavy processing such as calling an external API per row, the connection stays reserved for as long as the loop takes. Let's say you have to process 2000 rows, and it takes 1 second of processing per row. That means the database connection remains completely unusable for 2000 seconds, which is about 33 minutes! If you have a large app with more than 10,000 rows to process, that's about 3 hours.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Limited database connections&lt;/strong&gt;: During production, it is common for apps to use a limited connection pool. A &lt;code&gt;cursor()&lt;/code&gt; loop holding open the connection for a long time can affect other parts of the app, limiting their DB connections. &lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When &lt;code&gt;cursor()&lt;/code&gt; Can Be Useful:
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cursor()&lt;/code&gt; can be very useful in specific situations, where you must prioritize your server's memory over speed. Here are examples where it would be a lifesaver: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Exporting massive data&lt;/strong&gt;: Let's say you need to generate a CSV file with 500,000 rows. A standard query would crash your server due to memory overload. &lt;code&gt;cursor()&lt;/code&gt; effortlessly processess all the rows one by one, while keeping memory usage at virtually zero. &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Low traffic background jobs (queues)&lt;/strong&gt;: Running a loop in a background script (such as a Laravel Queue) late at night where not many users are around doesn't affect anything since there aren't actual users competing for DB connections. &lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;lazy()&lt;/code&gt; - The Most Misunderstood One
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&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="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="c1"&gt;// process each user&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Many developers share a very common misconception: &lt;code&gt;lazy()&lt;/code&gt; behaves just like &lt;code&gt;cursor()&lt;/code&gt;, using a single query. &lt;/p&gt;

&lt;p&gt;In reality, &lt;code&gt;lazy()&lt;/code&gt; uses the exact same mechanism as &lt;code&gt;chunk()&lt;/code&gt;, processing multiple rows at a time. The only difference is on the PHP side. &lt;code&gt;lazy()&lt;/code&gt; returns a &lt;code&gt;LazyCollection&lt;/code&gt;, giving you access to methods such as &lt;code&gt;map()&lt;/code&gt;, &lt;code&gt;filter()&lt;/code&gt;, and &lt;code&gt;each()&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="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lazy&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;filter&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;$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="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&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;$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="nf"&gt;sendNewsletter&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Same Twist
&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;lazy()&lt;/code&gt; follows the same architecture as &lt;code&gt;chunk()&lt;/code&gt;, it comes with the same mutation issue. When updating or deleting rows, some could be skipped or processed twice. The fix to this is &lt;code&gt;lazyById()&lt;/code&gt;, which paginates using row ID instead of offset, just like &lt;code&gt;chunkById()&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="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lazyById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&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="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="c1"&gt;// safe even if you delete rows inside the loop&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;code&gt;chunk()&lt;/code&gt; vs &lt;code&gt;cursor()&lt;/code&gt; vs &lt;code&gt;lazy()&lt;/code&gt; Summarized
&lt;/h2&gt;

&lt;p&gt;Here is a clear summary of the comparison between the three methods: &lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Query pattern&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chunk()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Many small queries&lt;/td&gt;
&lt;td&gt;Offset-based pagination, batch by batch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cursor()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One query&lt;/td&gt;
&lt;td&gt;PHP generator that streams one row at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lazy()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Many small queries&lt;/td&gt;
&lt;td&gt;Same mechanism as &lt;code&gt;chunk()&lt;/code&gt;, chainable on top&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Benchmark: The Ultimate Plot Twist
&lt;/h2&gt;

&lt;p&gt;To see the true differences between the three methods in action, I benchmarked them against 100,000 rows (used the same 100,000 rows for each method), measuring peak memory, number of queries, and execution time. If you want to test out this benchmark yourself or check out the setup, the full implementation is on GitHub: &lt;a href="https://github.com/EliasAlrgeaiDev/laravel-chunk-cursor-lazy-benchmark" rel="noopener noreferrer"&gt;https://github.com/EliasAlrgeaiDev/laravel-chunk-cursor-lazy-benchmark&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$startMemory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$startTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$queryCount&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="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;listen&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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$queryCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$queryCount&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="c1"&gt;// run the loop here&lt;/span&gt;

&lt;span class="nv"&gt;$peakMemory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$startMemory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$executionTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$startTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a table displaying the benchmark results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;th&gt;Query count&lt;/th&gt;
&lt;th&gt;Execution time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chunk()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.52 MB&lt;/td&gt;
&lt;td&gt;501 queries&lt;/td&gt;
&lt;td&gt;19.44s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cursor()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;17.6 MB&lt;/td&gt;
&lt;td&gt;1 query&lt;/td&gt;
&lt;td&gt;19.86s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lazy()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.52 MB&lt;/td&gt;
&lt;td&gt;501 queries&lt;/td&gt;
&lt;td&gt;19.39s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Plot Twist
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cursor()&lt;/code&gt; is supposed to be the one using the least memory, processing one row at a time, holding almost nothing in memory. But it completely caught me off guard when it used up more memory than &lt;code&gt;chunk()&lt;/code&gt; and &lt;code&gt;lazy()&lt;/code&gt; combined.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause:&lt;/strong&gt; Laravel &lt;strong&gt;&lt;em&gt;thinks&lt;/em&gt;&lt;/strong&gt; it's doing the right thing. However, the query didn't actually hit the database. It was given to PHP's PDO (PHP data object), which by default fetches and holds the entire dataset into memory at once, then gives Laravel each row one at a time, defeating the entire purpose of using &lt;code&gt;cursor()&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;**&lt;strong&gt;&lt;em&gt;Note for non-MySQL devs:&lt;/em&gt;&lt;/strong&gt;** This behavior only happens with MySQL databases, so if you use something else such as PostgreSQL or SQLite, you don't have to worry about any of this. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; is to tell PHP to stop hoarding all the data. This forces it to pull only one row at a time from the database, like how it's supposed to. To apply the fix, open &lt;code&gt;config/database.php&lt;/code&gt;, go to the MySQL connection array, and update &lt;code&gt;options&lt;/code&gt; to this:&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/database.php, inside the mysql connection array&lt;/span&gt;
&lt;span class="s1"&gt;'options'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="no"&gt;PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MYSQL_ATTR_USE_BUFFERED_QUERY&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I re-ran the benchmark, &lt;code&gt;cursor()&lt;/code&gt; behaved just like expected, using the least memory:&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="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.48&lt;/span&gt; &lt;span class="no"&gt;MB&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;20.05&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why use &lt;code&gt;chunk()&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;chunk()&lt;/code&gt; and &lt;code&gt;lazy()&lt;/code&gt; performed almost exactly the same, then why does &lt;code&gt;chunk()&lt;/code&gt; even exist if &lt;code&gt;lazy()&lt;/code&gt; comes with nicer chainable methods while putting up the same numbers? &lt;code&gt;lazy()&lt;/code&gt; is the latest one and the more ideal to use, however &lt;code&gt;chunk()&lt;/code&gt; does come with 2 benefits in specific scenarios: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Batch level logic:&lt;/strong&gt; &lt;code&gt;chunk()&lt;/code&gt; lets you handle the entire batch as one group, like sending a notification every 200 rows processed, while &lt;code&gt;lazy()&lt;/code&gt; only allows you to handle each row separately, so you need to write more syntax to mimic the same design. &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Legacy code:&lt;/strong&gt; &lt;code&gt;lazy()&lt;/code&gt; was introduced in Laravel 8, so older projects heavily rely on &lt;code&gt;chunk()&lt;/code&gt;. Continuing its use keeps things consistent. &lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Wrapping up
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;chunk()&lt;/code&gt;, &lt;code&gt;cursor()&lt;/code&gt;, and &lt;code&gt;lazy()&lt;/code&gt; all solve the same memory issue, but they have their own methods of solving it. &lt;code&gt;chunk()&lt;/code&gt; and &lt;code&gt;lazy()&lt;/code&gt; follow the same architectural idea, processing the dataset in small trips. The only difference is that &lt;code&gt;lazy()&lt;/code&gt; comes with more chainable methods, while &lt;code&gt;chunk()&lt;/code&gt; is better if you need batch-level logic or you're working with a legacy codebase. &lt;code&gt;cursor()&lt;/code&gt; works completely different, sending one big query requesting the entire dataset, but recieving each row one at a time. &lt;/p&gt;

&lt;p&gt;Here is a reference summarizing the differences between the 3 methods:&lt;/p&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reading only, no chaining needed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chunk()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mutating rows mid-loop&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;chunkById()&lt;/code&gt; or &lt;code&gt;lazyById()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need Collection methods (&lt;code&gt;map&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lazy()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lowest memory usage&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cursor()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None are wrong. It only depends on your need. Whether you prioritize saving memory, keeping old code consistent, or chainable methods, all three solve the same memory issue.&lt;/p&gt;

&lt;p&gt;If you want to test out this benchmark yourself or check out the setup, the full implementation is on GitHub: &lt;a href="https://github.com/EliasAlrgeaiDev/laravel-chunk-cursor-lazy-benchmark" rel="noopener noreferrer"&gt;https://github.com/EliasAlrgeaiDev/laravel-chunk-cursor-lazy-benchmark&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>webdev</category>
      <category>php</category>
      <category>backend</category>
    </item>
    <item>
      <title>I Made Cron and Queues Share the Work in Laravel — Here's My System</title>
      <dc:creator>Elias Alrgeai</dc:creator>
      <pubDate>Sun, 26 Jul 2026 07:00:02 +0000</pubDate>
      <link>https://dev.to/eliasalrgeaidev/i-made-cron-and-queues-share-the-work-in-laravel-heres-my-system-1jpn</link>
      <guid>https://dev.to/eliasalrgeaidev/i-made-cron-and-queues-share-the-work-in-laravel-heres-my-system-1jpn</guid>
      <description>&lt;p&gt;I used to think that cron jobs and queues are two different solutions, but now I realized they aren't competitors, they are meant to work together. I built a subscription system with automatic renewal billing called &lt;strong&gt;&lt;a href="https://github.com/EliasAlrgeaiDev/SubEngine" rel="noopener noreferrer"&gt;SubEngine&lt;/a&gt;&lt;/strong&gt;, and found that integrating queues with cron jobs is highly efficient. Cron handles schedules. Queues handle events.&lt;/p&gt;

&lt;p&gt;Here's how I split the work between the two, and why it remains resilient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron vs Queues
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cron&lt;/strong&gt; runs on a timer. "Every day at midnight, do X."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue&lt;/strong&gt; runs on an event. "This just happened, go handle it."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both seem like 2 different solutions to a problem, and that’s what it seems at first glance. But implementing the two together creates a highly efficient system, which I will explain why, and how I did it in SubEngine.&lt;/p&gt;

&lt;h2&gt;
  
  
  My System (SubEngine)
&lt;/h2&gt;

&lt;p&gt;In SubEngine, every subscription with auto-renewal toggled gets a renewal attempt a day before it expires. Whether the renewal failed or succeeded, an email gets sent to the user. Here is how I split the work here: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7v7hg21bhl4o9avunnww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7v7hg21bhl4o9avunnww.png" alt=" " width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cron job's only task is to find who needs a renewal, and attempts&lt;br&gt;
it. It doesn't send anything. Once the renewal attempt is &lt;br&gt;
completed, that outcome, either success or failure, becomes an event. The event gets pushed to the queue, and the queue sends the email to the user.&lt;/p&gt;

&lt;p&gt;Cron never touches email. Queue never touches renewal logic. &lt;br&gt;
Two jobs, two responsibilities, no overlap.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why This Split Makes a Difference
&lt;/h2&gt;

&lt;p&gt;If cron sent the email directly, it must wait on your email provider to respond before moving to the next user. If the provider is slow or down, the entire renewal batch slows down or fails. &lt;/p&gt;

&lt;p&gt;By pushing the email work to the queue, the cron remains fast and simple, while the queue can handle retries if the email fails, without ever touching renewal logic. Two systems that can fail independently without taking each other down. &lt;/p&gt;
&lt;h2&gt;
  
  
  A Quick Note on Scaling
&lt;/h2&gt;

&lt;p&gt;This system works exceptionally on a moderate number of users, but if you've got thousands of subscriptions renewing at once, the cron begins to slow down, and your back to one process doing too much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Don't renew inside the cron job itself. Let the cron find who needs renewing, then dispatch a job to the queue per user:&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="nc"&gt;Subscription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dueTomorrow&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;lazy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&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="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProcessRenewal&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&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;Now cron's only job is deciding &lt;em&gt;who&lt;/em&gt; needs checking. The actual &lt;br&gt;
renewal work happens in parallel, across your queue workers. Cron &lt;br&gt;
gets faster, and your system scales without a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Cron decides when. Queues decide what happens next. That split is what makes the system fast and reliable.&lt;/p&gt;

&lt;p&gt;If you want to see a complete implementation, check out &lt;strong&gt;&lt;a href="https://github.com/EliasAlrgeaiDev/SubEngine" rel="noopener noreferrer"&gt;SubEngine&lt;/a&gt;&lt;/strong&gt;. If you're solving this differently, I'd love to know your method.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Midnight Automated Billing Tasks Eventually Fail (And How to Fix It In Laravel 13)</title>
      <dc:creator>Elias Alrgeai</dc:creator>
      <pubDate>Wed, 08 Jul 2026 14:15:51 +0000</pubDate>
      <link>https://dev.to/eliasalrgeaidev/why-midnight-automated-billing-tasks-eventually-fail-and-how-to-fix-it-in-laravel-13-3p3</link>
      <guid>https://dev.to/eliasalrgeaidev/why-midnight-automated-billing-tasks-eventually-fail-and-how-to-fix-it-in-laravel-13-3p3</guid>
      <description>&lt;p&gt;It's 12:00 AM. Your cron job begins, like it does every night.&lt;/p&gt;

&lt;p&gt;Except tonight, you have 40,000 active subscribers instead of 400.&lt;/p&gt;

&lt;p&gt;The job begins charging every subscription that's due to expire that day, and in a few seconds, your database connections get maxed out. Stripe may begin returning &lt;code&gt;429 Too Many Requests&lt;/code&gt; errors. Then PHP times out. Now you don't know who actually got charged and who didn't.&lt;/p&gt;

&lt;p&gt;This is the midnight cron nightmare. It's one of the most common yet avoidable flaws in subscription billing systems, and almost every self-built backend hits it at one point. Here's exactly why it happens, and how to build it correctly in Laravel 13.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Straightforward Approach (And Why It Feels Fine at First)
&lt;/h2&gt;

&lt;p&gt;Most subscription systems start out with something like this:&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schedule&lt;/span&gt; &lt;span class="nv"&gt;$schedule&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;$schedule&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;call&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Subscription&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ends_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'active'&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;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&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;chargeSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&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;dailyAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'00:00'&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;It's very simple, readable, and works perfectly fine in local dev with 10 test subscriptions. It'll probably survive your first few hundred users too. But this approach doesn't fail because it's wrong on day one. It fails because it's a ticking clock tied directly to your growth curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;There are four massive issues hiding behind that simple loop:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The Chokepoint&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If every subscription is set to renew "at expiration," and most of your users signed up around the same time (which happens naturally with growth spikes, promotions, or seasonal signups), you get a massive cluster of renewals landing on the exact same timestamp. This creates one enormous batch with zero distribution. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Synchronous Charging Inside a Loop&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Calling a payment API synchronously inside a &lt;code&gt;foreach&lt;/code&gt;, inside a scheduled command, means your total runtime is &lt;code&gt;(number of subscriptions) × (Stripe API latency)&lt;/code&gt;. At 40,000 subscriptions and ~300ms per charge, that's over three hours in a single PHP process. That process holds a database connection open the whole time, has a &lt;code&gt;max_execution_time&lt;/code&gt; limit, and has no way to resume if it crashes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Stripe (and your database) Will Rate-Limit You&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Payment providers cap requests per second for a reason. Hit the API synchronously from one process and you'll get throttled. Throttled requests with no backoff just fail. And if you retry without idempotency, those failures can turn into duplicate charges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. No Idempotency = No Safety Net&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the job crashes at subscription #22,000 and you simply re-run it, you now risk re-charging the first 22,000 users who already succeeded. If you aren't tracking which renewals already ran, every retry risks double-charging your users.&lt;/p&gt;

&lt;p&gt;None of these are edge cases. They're the default behavior of the naive cron approach once you cross a few thousand active subscriptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Professional Fix: Space-out, Queue, and Track
&lt;/h2&gt;

&lt;p&gt;The fix isn't a bigger server or a longer timeout. It's rethinking three things: &lt;em&gt;when&lt;/em&gt; renewals happen, &lt;em&gt;how&lt;/em&gt; they're executed, and &lt;em&gt;what proof&lt;/em&gt; you keep that they happened. Here's the shape of it end to end:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm74avlxutm930t3hzgzy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm74avlxutm930t3hzgzy.png" alt="Subscription Billing Workflow Sequence" width="800" height="824"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Stagger Billing Windows — Don't Renew Everyone at Once
&lt;/h3&gt;

&lt;p&gt;Instead of one giant midnight batch, spread renewal &lt;em&gt;attempts&lt;/em&gt; across a window, and separate the renewal attempt from the actual expiration:&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;// Two distinct timestamps instead of one&lt;/span&gt;
&lt;span class="n"&gt;renewal_due_at&lt;/span&gt;  &lt;span class="c1"&gt;// when we should attempt a renew&lt;/span&gt;
&lt;span class="n"&gt;ends_at&lt;/span&gt;         &lt;span class="c1"&gt;// when access actually ends if renewal fails&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attempting renewal a day or two &lt;em&gt;before&lt;/em&gt; &lt;code&gt;ends_at&lt;/code&gt; rather than exactly at expiration gives you room to retry failed charges, notify the user, and smoothly kill access if the payment fails, without giving away free unpaid access or charging everyone at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Never Charge Synchronously Inside the Scheduler
&lt;/h3&gt;

&lt;p&gt;The scheduler's only job should be to find &lt;em&gt;who&lt;/em&gt; needs billing and dispatch work — not to do the billing itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schedule&lt;/span&gt; &lt;span class="nv"&gt;$schedule&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;$schedule&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;call&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Subscription&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'renewal_due_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auto_renew'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chunkById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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="nv"&gt;$subscriptions&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;$subscriptions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;ProcessRenewal&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subscription&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dailyAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"00:00"&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;Two important changes here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;chunkById()&lt;/code&gt; instead of &lt;code&gt;get()&lt;/code&gt;&lt;/strong&gt; — you never load 40,000 Eloquent models into memory at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dispatch a queued job per subscription instead of charging inline&lt;/strong&gt; — the scheduler's job finishes in milliseconds. The actual charging happens in the queue, where Laravel's worker pool naturally throttles concurrency, retries failed jobs, and won't take your whole app down if one charge hangs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice there's no &lt;code&gt;-&amp;gt;onQueue('billing')&lt;/code&gt; at the call site. Laravel 13 added &lt;code&gt;Queue::route()&lt;/code&gt;, which lets you define the queue and connection in one place instead of repeating it every time you dispatch a job:&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="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProcessRenewal&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;php artisan queue:work&lt;/code&gt; with a sane &lt;code&gt;--tries&lt;/code&gt; and backoff strategy turns "one giant fragile loop" into "many small, retryable, independently-failing units of work", which is the more reliable and stable approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Make Every Renewal Attempt Idempotent
&lt;/h3&gt;

&lt;p&gt;This is the piece most naive implementations skip entirely, and it's the one that actually saves you from double-charging customers on retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessRenewal&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Bail if this cycle already resolved — either the renewal&lt;/span&gt;
        &lt;span class="c1"&gt;// already succeeded (renewal_due_at moved forward) or it&lt;/span&gt;
        &lt;span class="c1"&gt;// already failed (auto_renew got turned off).&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;auto_renew&lt;/span&gt; &lt;span class="o"&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;subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;renewal_due_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isFuture&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="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&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;chargeCustomer&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;subscription&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;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;successful&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;subscription&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="s1"&gt;'renewal_due_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addMonth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="s1"&gt;'ends_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addMonth&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;else&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;subscription&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="s1"&gt;'auto_renew'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&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;subscription&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RenewalFailed&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;subscription&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;This checks real state, not a timestamp: if the renewal succeeded, &lt;code&gt;renewal_due_at&lt;/code&gt; is already in the future. If it failed, &lt;code&gt;auto_renew&lt;/code&gt; is already off. Either way, the job bails. If neither happened yet, it means the last attempt didn't reach an outcome, so a retry from Laravel's &lt;code&gt;--tries&lt;/code&gt;/backoff mechanism can still fire and actually try the charge again. Pair this with treating the webhook as the real source of truth for a successful charge, and you've closed off the two biggest sources of billing bugs: duplicate charges and false-positive activations.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Keep Payment History, Don't Overwrite State
&lt;/h3&gt;

&lt;p&gt;One more habit worth adopting: never delete or overwrite a payment record. Every attempt (successful, failed, or pending) should be its own row. When a customer emails asking "why was I charged twice" or "why did my renewal fail," you want a full audit trail, not a single &lt;code&gt;status&lt;/code&gt; column that only remembers the most recent outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;The shift is simple to state and easy to underestimate: &lt;strong&gt;stop treating billing as a loop, and start treating it as a distributed, idempotent, queue-driven sequence.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stagger renewal windows so you're not billing everyone at once&lt;/li&gt;
&lt;li&gt;Let the scheduler dispatch, not execute&lt;/li&gt;
&lt;li&gt;Queue the actual charge with retries and backoff&lt;/li&gt;
&lt;li&gt;Track idempotency so retries are always safe&lt;/li&gt;
&lt;li&gt;Trust webhooks over synchronous responses&lt;/li&gt;
&lt;li&gt;Keep full payment history for every attempt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. Separating &lt;em&gt;scheduling&lt;/em&gt; from &lt;em&gt;doing&lt;/em&gt;, and &lt;em&gt;attempting&lt;/em&gt; from &lt;em&gt;confirming&lt;/em&gt;. But it's the difference between a billing system that quietly scales past 40,000 users and one that wakes you up at 3 AM.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Reference Implementation
&lt;/h2&gt;

&lt;p&gt;I ended up building this system in an open-source Laravel 13 backend called &lt;strong&gt;&lt;a href="https://github.com/EliasAlrgeaiDev/SubEngine" rel="noopener noreferrer"&gt;SubEngine&lt;/a&gt;&lt;/strong&gt; — a webhook-driven subscription billing engine that implements strategic renewal timing, idempotent scheduler jobs, Stripe webhook verification, and full payment history preservation, with 31 passing tests covering the lifecycle end to end.&lt;/p&gt;

&lt;p&gt;It's lightweight, focused on the backend billing logic that tends to break in the real world. If you're building (or rebuilding) subscription billing in Laravel, it's worth a look as a reference or a starting template.&lt;/p&gt;

&lt;p&gt;If this saved you from a future midnight crash, a star on the repo goes a long way.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>webdev</category>
      <category>php</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
