Laravel catch NotFoundException global model observer job

91 Views Asked by At

I'm working in a Laravel 10 application. My application has queued event listeners. My project is using Laravel Horizon to process queued jobs and events, I've got a notification feature that applies a global model observer to all models that then dispatches my ProcessModelObserver job when it's create, updated, deleted etc.

The problem I've got, in some areas of my application it looks like the job is processed before the model is saved and thus throws an error:

Illuminate\Database\Eloquent\ModelNotFoundException: No query results for model

For my circumstances, I'd rather catch this somehow so that it doesn't show up as a failed job in Horizon, but where I've added this catch already appears to not be catching the error.

What am I missing to achieve this, here's my files:

EventServiceProvider boot method

**
 * Register any events for your application.
 */
public function boot(): void
{
    $modelsToObserve = [
        \App\Models\Affiliate::class,
        \App\Models\AffiliateCampaign::class,
        \App\Models\AffiliateProduct::class,
        \App\Models\AffiliateSplit::class,
        \App\Models\Analytic::class,
        \App\Models\ApiRequestLog::class,
        \App\Models\Application::class,
        \App\Models\Buyer::class,
        \App\Models\BuyerTier::class,
        \App\Models\BuyerTierOption::class,
        \App\Models\Country::class,
        \App\Models\Pingtree::class,
        \App\Models\PingtreeGroup::class,
        \App\Models\Product::class,
        \App\Models\Setting::class,
    ];

    foreach ($modelsToObserve as $model) {
        try {
            $model::observe(GlobalModelObserver::class);
        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            Log::info("ModelNotFoundException"); <-- not catching anything?
        }
    }
}

GlobalModelObserver.php file

None of the ModelNotFoundException are being caught here either.

<?php

namespace App\Observers;

use App\Contracts\ListensForChangesContract;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use App\Jobs\ProcessModelObserver;
use Carbon\Carbon;
use Exception;

class GlobalModelObserver
{
    /**
     * Handle the User "created" event.
     */
    public function created($model): void
    {
        if (! $model) return;

        try {
            ProcessModelObserver::dispatch($model, $model->id, 'created', Carbon::now());
        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            Log::info("ModelNotFoundException");
        } catch (Exception $e) {
            // ...
        }
    }

    /**
     * Handle the User "updated" event.
     */
    public function updated($model): void
    {
        if (! $model) return;

        try {
            $changes = Arr::except(
                $model->getDirty(),
                $model->excludeFromNotificationComparison()
            );

            // only dispatch if there are differences
            if (count($changes) > 0) {
                ProcessModelObserver::dispatch($model, $model->id, 'updated', Carbon::now(), [
                    'model' => [
                        'comparison' => [
                            'original' => collect($model->getOriginal())->toArray(),
                            'current' => collect($model->getAttributes())->toArray(),
                        ],
                        'changes_since_last_update' => collect($changes)->toArray(),
                    ]
                ]);
            }

        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            Log::info("ModelNotFoundException");
        } catch (Exception $e) {
            // ...
        }
    }

    /**
     * Handle the User "deleted" event.
     */
    public function deleted($model): void
    {
        if (! $model) return;

        try {
            ProcessModelObserver::dispatch($model, $model->id, 'deleted', Carbon::now());
        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            Log::info("ModelNotFoundException");
        } catch (Exception $e) {
            // ...
        }
    }

    /**
     * Handle the User "force deleted" event.
     */
    public function forceDeleted($model): void
    {
        if (! $model) return;

        try {
            ProcessModelObserver::dispatch($model, $model->id, 'deleted', Carbon::now());
        } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
            Log::info("ModelNotFoundException");
        } catch (Exception $e) {
            // ...
        }
    }
}

ProcessModelObserver job

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Notifications\Models\ModelStored;
use App\Notifications\Models\ModelUpdated;
use App\Notifications\Models\ModelDestroyed;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use App\Models\User;
use App\Models\Company;
use App\Models\CompanyEntry;
use Carbon\Carbon;
use Exception;

class ProcessModelObserver implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * The number of times the job may be attempted.
     *
     * @var int
     */
    public $tries = 2;

    /**
     * The number of seconds the job can run before timing out.
     *
     * @var int
     */
    public $timeout = 40;

    /**
     * The model
     */
    public $model;

    /**
     * The model id
     */
    public $id;

    /**
     * The model event
     */
    public $event;

    /**
     * The datetime it happened
     */
    public $datetime;

    /**
     * The datetime it happened
     */
    public $metadata;

    /**
     * Create a new job instance.
     */
    public function __construct($model, int $id, string $event, Carbon $datetime, array $metadata = [])
    {
        $this->onQueue('observers');
        $this->model = $model;
        $this->id = $id;
        $this->event = $event;
        $this->datetime = $datetime;
        $this->metadata = $metadata;
    }

    /**
     * Calculate the number of seconds to wait before retrying the job.
     */
    public function backoff(): array
    {
        return [1, 3];
    }

    /**
     * Get the tags that should be assigned to the job.
     *
     * @return array<int, string>
     */
    public function tags(): array
    {
        return ['processModelObserver'];
    }

    /**
     * Return the model
     */
    public function getModel()
    {
        $base = class_basename($this->model);

        $model = "App\\Models\\$base";

        return $model::find($this->id);
    }

    /**
     * Determine whether the user has notification channels to send to
     */
    public function userHasNotificationChannels(User $user, string $type): bool
    {
        $channels = $user->notificationPreferences()
                         ->where('type', $type)
                         ->with('notificationChannels')
                         ->get()
                         ->pluck('notificationChannels')
                         ->flatten()
                         ->pluck('name')
                         ->unique()
                         ->toArray();

        return !empty($channels) ? true : false;
    }

    /**
     * Return the model title
     */
    public function getModelTitle(): string
    {
        $title = class_basename($this->model);

        try {
            $title = $this->model->notificationDynamicTitle();
        } catch (Exception $e) {
            // ...
        }

        return $title;
    }

    /**
     * Notify the user
     */
    public function scheduleNotificationToSend(User $user): void
    {
        $base = class_basename($this->model);

        $title = $this->getModelTitle();

        $timestamp = Carbon::parse($user->next_notifiable_datetime)->toDateTimeString();
        $sendAt = Carbon::createFromFormat('Y-m-d H:i:s', $timestamp, $user->timezone);
        $sendAt->setTimezone('UTC');

        if ($sendAt->isPast()) {
            $sendAt = Carbon::now();
        }

        switch ($this->event) {
            case 'created':

                if (! $this->userHasNotificationChannels($user, Str::of($base.'_store')->snake())) {
                    return;
                }

                $user->notifyAt(
                    new ModelStored(
                        Str::of($base.'_store')->snake(),
                        "$title has been created",
                        "Model has been created",
                        $this->metadata
                    ), $sendAt
                );
                break;

            case 'updated':

                if (! $this->userHasNotificationChannels($user, Str::of($base.'_update')->snake())) {
                    return;
                }

                $user->notifyAt(
                    new ModelUpdated(
                        Str::of($base.'_update')->snake(),
                        "$title has been updated",
                        "Model has been updated",
                        $this->metadata
                    ), $sendAt
                );
                break;

            case 'deleted':

                if (! $this->userHasNotificationChannels($user, Str::of($base.'_destroy')->snake())) {
                    return;
                }

                $user->notifyAt(
                    new ModelDestroyed(
                        Str::of($base.'_destroy')->snake(),
                        "$title has been deleted",
                        "Model has been deleted",
                        $this->metadata
                    ), $sendAt
                );
                break;
        }
    }

    /**
     * Get company users
     */
    public function getCompanyUsers(int $companyId)
    {
        return Cache::tags([
            'company_entries'
        ])->remember('process_model_observer_company_users', 10, function () use ($companyId) {
            return CompanyEntry::where('company_id', $companyId)
                ->pluck('user_id')
                ->unique()
                ->map(function ($userId) {
                    return User::find($userId);
                })
                ->filter();
        });
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        $base = class_basename($this->model);
        $model = $this->getModel();

        if (! $model) {
            $this->fail("Model $base with id $this->id can't be found.");
            return;
        }

        if (! isset($model->company_id)) {
            return;
        }

        $users = $this->getCompanyUsers($model->company_id);

        if (empty($users)) {
            return;
        }

        foreach ($users as $user) {
            if (! empty($user->next_notifiable_datetime)) {
                $this->scheduleNotificationToSend($user);
            }
        }
    }
}

What am I missing again, I'd rather not show thousands of failed jobs for my circumstances and instead catch it.

2

There are 2 best solutions below

3
silver On

You have a lot going on in there, but it could be on your deleted observer job where you are still querying the model after its deleted and there it no actual issue when the event are fired, and only when it tries to deserialize the data from your job where your model is gone.

You can just handle the exception in your actual job handle,

e.i.

public function handle(): void {

    try {
        .
        .
        // your original handle code
        .
        .
    }  catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) {     
        $data = [
            'model' => $this->model,
            'id'    => $this->id,
            'event' => $this->event,
            'datetime' => $this->datetime,
            'metadata' => $this->metadata,
        ]
        // Log it so you can see what the hell is going on
        \Log::info( json_encode($data) );

        // Tell queue everything is alright.
        return;

    } catch (\Throwable $th) {
        // Fail the job for other errors.
        throw $th;
    }    
    
}
2
Rafael Xavier On

Did you try to use \Throwlable ? Like this code bellow:

try {
     // Your code
} catch (\Throwlable $th) {
     Log::info($th->getMessage);
}