Tip: Ensure model events are fired in your tests when using Model Observers

Published: 24.10.23 Last Updated: 24.10.23

We will look at a couple of common scenarios where adding a couple of lines to your tests can future-proof your application from data loss.

Note: I have found model observers to be a bit of an anti-pattern given some of the issues below as well as they can cause a bit of havoc when events are occurring unintentionally. However, given the scope of your project, they are perfectly fine tools to use initially to get things moving early on.

Example Scenario

If you are writing code that is dependent on model observers. For example, you are syncing a model called Configuration using Laravels Model Observers to an external database like Google's Firestore. You may end up writing eloquent code unwittingly that will not fire model events and therefore not sync your data correctly.

Take the following NewsletterConfigUpdateController for example:

<?php

namespace App\Http\Controllers;

use App\Enums\ConfigurationTypeEnum;
use App\Http\Requests\StoreNewsletterConfigRequest;
use App\Models\Configuration;

class NewsletterConfigUpdateController extends Controller
{
    public function __invoke(StoreNewsletterConfigRequest $request)
    {
        $validated = $request->validated();

        Configuration::updateOrCreate(
            [
                'user_id' => auth()->user()->id,
                'type' => ConfigurationTypeEnum::NewsletterApiKey->value,
            ],
            ['value' => $validated['newsletter_api_key']]
        );
        Configuration::updateOrCreate(
            [
                'user_id' => auth()->user()->id,
                'type' => ConfigurationTypeEnum::NewsletterListId->value,
            ],
            ['value' => $validated['newsletter_list_id']]
        );

        return redirect()->back()->with('status', __('Newsletter configuration updated successfully!'));
    }
}

To ensure that model events are fired we can add the following test. Note the 'eloquent.created: App\Models\Configuration'

/** @test */
public function user_can_save_newsletter_config_data_and_fire_eloquent_events(): void
{
    Event::fake();
    $user = $this->signIn();
    Configuration::factory()->newsletterApiKey('api_key_123')->withUser($user)->createQuietly();

    $response = $this->patch(route('newsletter-config.update'), [
        'newsletter_api_key' => 'api_key_123',
        'newsletter_list_id' => 'list_id_123',
    ]);

  	Event::assertDispatched('eloquent.created: App\Models\Configuration', 1);
  	Event::assertDispatched('eloquent.updated: App\Models\Configuration', 1);
    $this->assertCount(2, Configuration::all());
}

Looks good, now if a few weeks later a colleague or yourself comes back and look at the code they may want to optimize the database queries by using Laravels upsert functionality like so.

<?php

namespace App\Http\Controllers;

use App\Enums\ConfigurationTypeEnum;
use App\Http\Requests\StoreNewsletterConfigRequest;
use App\Models\Configuration;

class NewsletterConfigUpdateController extends Controller
{
    public function __invoke(StoreNewsletterConfigRequest $request)
    {
        $validated = $request->validated();

        Configuration::upsert([
            [
                'user_id' => auth()->user()->id,
                'type' => ConfigurationTypeEnum::NewsletterApiKey->value,
                'value' => $validated['newsletter_api_key'],
            ],
            [
                'user_id' => auth()->user()->id,
                'type' => ConfigurationTypeEnum::NewsletterListId->value,
                'value' => $validated['newsletter_list_id'],
            ],
        ], ['user_id', 'type'], ['value']);

        return redirect()->back()->with('status', __('Newsletter configuration updated successfully!'));
    }
}

When you now run the tests the assertion for Event::assertDispatched('eloquent.created: App\Models\Configuration', 1); and Event::assertDispatched('eloquent.updated: App\Models\Configuration', 1); will fail thus saving code getting shipped that may incur data loss occurring as a side effect of eloquent events not firing correctly.

This same methodology can be applied to deletes for example if running a delete query and you are dependent on the model observer then you should hydrate the eloquent model first before deleting it like so.

Configuration::where('id', $configId)
    ->first()
    ?->delete();

You can then ensure it's deleted as before with Event::assertDispatched('eloquent.deleted: App\Models\Configuration', 1);

This is often unnecessary when using CRUD routes as the model will be hydrated when it is injected into the controller using route model binding, but if you find yourself writing a query like this while using model observers best to add an assertion for the dispatched event just to be safe.

Next Steps

There are a couple of ways to engineer around this issue. Since setting up poor-performing DB queries could be a bottleneck later on once you have sizeable traffic.

Option One: Leverage Postgres Using a DB like Postgres that will instead of returning the affected row count like MySQL return the IDs of the rows affected. In this case, you can then fire whatever event you need with these events and handle it async.

Option Two: MySQL replication using Binlogs This has a larger DevOps footprint but would allow you to stick with MySQL and set up an ETL-type process to read the bin log and replicate that data to your needs to another destination. If you want to stick with a PHP implementation you can check out krowinski/php-mysql-replication.


Have questions or want to stay up to date? Find me on Twitter Get notified of new posts