Skip to main content
← All use cases

How to Monitor php artisan notifications:prune in Production

php artisan notifications:prune is easy to ignore until the notifications table starts growing without bound. It can miss a run, take too long, or fail inside schedule:run without anything obvious surfacing.

If you’re searching for how to monitor php artisan notifications:prune in production, the real goal is to catch missed runs, slow executions, and silent failures before the notifications table balloons.

The dangerous part is that the command may keep exiting 0 while the row count keeps climbing.

How notifications:prune Actually Works

Laravel’s notifications system stores database notifications in the notifications table by default. The notifications:prune command deletes records older than a configurable retention period:

php artisan notifications:prune --hours=48

This tells Laravel to delete any notification record where created_at is older than 48 hours. Behind the scenes, it runs a chunked delete to avoid locking the table:

// vendor/laravel/framework/src/Illuminate/Notifications/Console/PruneCommand.php
$executed = $model::pruneAll($prunable);

The model is configurable, but defaults to DatabaseNotification from the notifications table. The command is designed to be safe: it chunks deletions in batches of 100 (or whatever you configure) to avoid long-running transactions.

The catch is that notifications:prune is an artisan command, not a queued job. It runs inside schedule:run, which means if your scheduler is down, the prune command never fires.

Common Failure Modes

The scheduler stops but nobody notices. If schedule:run crashes or the cron entry disappears, every scheduled task stops including your prune job. The notifications table grows unchecked until someone notices the database size.

Memory limits on large tables. If you have millions of rows, the default memory limit may cause the command to OOM before completing. The fix is setting a lower batch size with --chunk=500, but this means the command runs longer and is more likely to hit timeout issues.

Wrong retention window for your use case. If you set --hours=48 but your app shows notifications for 7 days, users will see notifications disappear while they’re still relevant. The prune schedule must match your actual notification lifetime, not an arbitrary number.

Permissions issues after deployments. If the web server user can’t write to the storage directory, or if the database user lacks DELETE permissions on the notifications table, the command fails silently inside schedule:run because you’re probably redirecting output to /dev/null.

Building Visibility Into notifications:prune

The most reliable approach is a scheduled heartbeat: an endpoint that fires before and after prune runs, so you know the command actually executed:

// In your console Kernel or routes/console.php
Schedule::command('notifications:prune --hours=48')
    ->daily()
    ->before(function () {
        Http::get(config('services.monitor.prune_url') . '/before');
    })
    ->after(function () {
        Http::get(config('services.monitor.prune_url') . '/after');
    });

If you only get the “before” ping and never the “after” ping within your expected window, the prune command crashed or timed out. Crontinel watches for the “after” signal and fires an alert when it goes missing.

You can also add logging to a custom notification model to track deletions:

class Notification extends Model
{
    protected static function booted()
    {
        static::deleted(function ($notification) {
            Log::channel('prune')->info('Pruned notification', [
                'id' => $notification->id,
                'type' => $notification->type,
                'age_hours' => now()->diffInHours($notification->created_at),
            ]);
        });
    }
}

This gives you a log file you can ship to your monitoring system to track how many notifications get pruned each run.

Detecting When Prune Fails

Check your notifications table row count over time. A healthy table should stay relatively stable once the prune job is running correctly. Set up a query that runs daily:

SELECT COUNT(*) as total,
       COUNT(CASE WHEN created_at < NOW() - INTERVAL 48 HOUR THEN 1 END) as overdue
FROM notifications;

If overdue is growing, your prune job is not keeping up. The reasons could be scheduler failure, slow deletions, or a retention window mismatch.

Crontinel runs this query or its equivalent via an external check and alerts when the overdue count exceeds your threshold. This catches prune failures even when Laravel itself does not log an error.

Production monitoring of notifications:prune is not glamorous, but a 4GB notifications table is worse. Catch the missed runs before they become a database emergency.

See also

Start monitoring in minutes

Free for one app. No account needed to install and test locally.

composer require crontinel/laravel
php artisan crontinel:install
Get early access