Notifications (in-app & email)
View on GitHubClean up old notifications and activity history
Notifications and activity log entries pile up forever unless something removes them. Orbit gives every user two ways to do that, both in Settings → Notifications:
- an automatic schedule (daily / weekly / monthly) that deletes notifications and the user's own activity entries older than N days;
- "Clean up now" buttons that do the same thing once, immediately.
This guide covers how it fits together and how to extend it (a new kind of data to clean, a new frequency).
The moving parts
notification_retention_settings (one row per user, optional)
enabled, frequency, notifications_older_than_days,
notifications_only_read, activity_older_than_days, last_run_at
│
routes/console.php ── Schedule::command('notifications:prune')->hourly()
│
PruneOldNotifications → NotificationRetentionService::runDue()
│
├─ NotificationRetentionRepository::getDue($now)
│ enabled AND (last_run_at IS NULL OR last_run_at <= now - frequency)
├─ apply(): purgeNotifications() / purgeActivity()
└─ stamps last_run_at (only if apply() did not throw)
- Model / table:
App\Models\NotificationRetentionSetting; one row per user (user_idunique, cascade-deleted with the user). No row means "schedule off",getSettings()fills in the defaults. - Frequency:
App\Enums\Notifications\RetentionFrequency(daily/weekly/monthly);days()is the minimum gap between two runs. - Why hourly? The command runs every hour but each schedule decides
whether a full period has passed, so users do not need a time of day
and a missed tick is harmless. A failure for one user, including the step that stamps
last_run_at, is logged and skipped (their schedule stays due), it never stops the others. Each schedule is processed in its own transaction: the row is locked (findForUserForUpdate) and re-read, so a user who just switched it off, turned on "keep unread" or deleted their account is not processed with the values from when the due list was built, a settings save waits for the run to commit, and a failure rolls the deletion back with the stamp. - What is deleted: notifications via
NotificationRepository::deleteOlderThanForUser()(optionally only read ones —notifications_only_read, on by default) and the user's ownactivity_logsrows (user_id = me) viaActivityLogRepository::deleteOlderThanForUser(). Entries written by other people are never touched, even in the same project. Note that the activity log feeds the project activity feed too, so removing your own old entries removes them there as well. - Cache: purging notifications calls
NotificationService::forget()so the shared prop and the unread badge do not show deleted rows.
HTTP
| Route | Controller | Purpose |
|---|---|---|
GET /settings/notifications | SettingsController::notifications | page; passes notificationRetention |
POST /account/notification-retention | NotificationRetentionController::update | save the schedule |
POST /account/notification-retention/purge | NotificationRetentionController::purge | run once: target (notifications|activity), days, only_read |
Validation: ages are integers 1..NotificationRetentionService::MAX_DAYS
(3650); a schedule cannot be switched on with both ages empty
(UpdateNotificationRetentionRequest).
Frontend
AccountSettingsNotificationCleanup.tsx (rendered by
AccountSettingsNotificationsTab.tsx) keeps a local draft of the
schedule, enables "Save schedule" only when it differs from what was
saved, and shows server errors inline. "Clean up now" uses an inline
two-step confirm because deletion cannot be undone. The age presets live
in one AGE_DAYS array.
Extending
Add another age preset
Add the number to AGE_DAYS in AccountSettingsNotificationCleanup.tsx.
The backend accepts any value up to MAX_DAYS, so nothing else changes.
Add a new frequency
- Add a case and its
days()toRetentionFrequency(the migration stores the string, so no schema change). - Add
{ value, label }toFREQUENCY_OPTIONSand the literal to theRetentionFrequencytype inresources/js/types/Notification.ts. getDue()iteratesRetentionFrequency::cases(), so it picks the new case up by itself.
Clean up another kind of data
- Add a nullable
<thing>_older_than_dayscolumn (new migration — never edit the existing one) and put it in the model's$fillable/casts. - Add a repository
deleteOlderThanForUser()-style method and apurge<Thing>()inNotificationRetentionService; call it fromapply(). - Extend
UpdateNotificationRetentionRequest,getSettings()and theNotificationRetentionTS type; add the row to the cleanup component and thetargetoption ofPurgeNotificationDataRequest. - Remember to drop any cache that holds the data you delete
(
CacheVersion::bump(...)for the shared lists, see../redis/RedisRedis is Orbit's cache, session and queue backend. Everything it holds is disposable except queued jobs and live sessions (it runs with append-only persist…).
Testing
- PHP:
tests/Feature/NotificationRetentionTest.phpcovers the due logic (period, disabled, failure isolation), the command, the schedule registration and every endpoint. Build old rows by passingcreated_atto the factory. - JS:
AccountSettingsNotificationCleanup.test.tsx. - Run the schedule by hand:
php artisan notifications:prune.
