Watch out when calling methods that trigger lifecycle events when inside lifecycle events

This is part of the Semicolon&Sons Code Diary - consisting of lessons learned on the job. You're in the object-oriented-programming category.

Last Updated: 2024-04-25

I had an infinite loop bug when using an observer connected to the updated callback in an eloquent ORM mode (for an Advisor - which corresponds to a "tax advisor" in real life).

<?php

class AdvisorObserver
  public function updated(Advisor $advisor)
  {
    // Strip non-digits from phone/fax
    $advisor->phone = preg_replace('~\D~', '', $advisor->phone);
    $advisor->save();
    UpdateAdvisorGeocoordinates::dispatch($advisor);
  }
}

// Wiring up the observer
class AppServiceProvider extends ServiceProvider
{
  public function boot()
  {
    Advisor::observe(AdvisorObserver::class);
  }
}

The reason this went on an infinite loop was because I called save() in the updated() callback, which triggered this exact same code again.

I could have avoided this:

  1. With some code that checks if the attributes were dirty - e.g. isDirty(["phone", "fax"]) - that way there would be a modification (and a save) after the first iteration, but not the second.
  2. By instead hooking into an earlier lifecycle method, saving, with the expectation that the modified data would be saved during the later call to save() in the lifecycle. But be careful here: if I were to also dispatch the UpdateAdvisorGeocoordinates work to the queue in this saving method, then it would receive a stale version of the data without these (as of the moment) unsaved changes. Better to retain the dispatching to the queue in the updated method.
  3. By unsetting callbacks for a while:
  <?php

    Advisor::withoutEvents(function () {
      $advisor->save();
    });

Another complication cropped up when code that normally occurs in a background queue is instead executed in-line (i.e. synchronously), as might happen in a unit testing environments. Since, by their nature, a background job has to save its changes, this can cause unexpected test failures and infinite loops.