DEV Community

Sebastian Cabarcas
Sebastian Cabarcas

Posted on

Per-user notification preferences in Laravel without rewriting via()

Every SaaS ends up building the same thing: a notification settings screen where users opt in/out of email, push, Slack, etc., per category. The logic always leaks into via() arrays and policy classes scattered across the app.

I extracted this into a package: Laravel Notify Matrix.

The problem

// Before — what every Laravel app eventually does:
public function via($notifiable): array
{
    $channels = ['database'];

    if ($notifiable->wantsMail('orders')) {
        $channels[] = 'mail';
    }

    if ($notifiable->wantsSlack('orders') && config('app.slack_enabled')) {
        $channels[] = 'slack';
    }

    return $channels;
}
Enter fullscreen mode Exit fullscreen mode

The package

// User model
use Scabarcas\LaravelNotifyMatrix\Concerns\HasNotificationPreferences;

class User extends Authenticatable
{
    use HasNotificationPreferences;
}

// Notification class
use Scabarcas\LaravelNotifyMatrix\Attributes\NotificationGroup;

#[NotificationGroup('orders')]
class OrderShipped extends Notification
{
    public function via($notifiable): array
    {
        return ['mail', 'database'];
    }
}

// Anywhere
$user->wants('orders', 'mail');
$user->disable('orders', 'mail');
$user->enable('orders', 'database');
$user->getPreferencesForGroup('orders');
$user->clearPreferences('orders');
Enter fullscreen mode Exit fullscreen mode

The via() method stays the same — declarative, naive, no conditionals.

How it works under the hood

A listener on Illuminate\Notifications\Events\NotificationSending returns false for channels the user has opted out of. Forced channels bypass user preferences. Notifications without a #[NotificationGroup] attribute or class_map entry are ignored entirely.

The resolution order:

  1. Channel is in groups.<group>.forced → deliver.
  2. User has a stored preference row → use it.
  3. Otherwise, group default_policy decides (with fallback to the global default).

Architecture notes

  • PreferenceManager orchestrates resolution.
  • PreferenceRepository and GroupResolver are interfaces — swap for Redis, event-based resolvers, or anything else.
  • The trait is a thin facade over the manager.
  • Tests run on Pest + Orchestra Testbench against SQLite in-memory.

Links

Feedback welcome.

Top comments (0)