Notifications (in-app & email)
View on GitHubSend a notification from your code
Guide 01 covers wiring a domain event through SendNotificationListener
into NotificationService::notify(). You don't always need an event
in between — notify() is a normal service method, callable directly
from anywhere, and going through an event only matters when more than
one consumer needs to react to the same fact (notifications and
integrations, say). Worked example: a "Remind assignee" button that
sends a one-off reminder — a deliberate, single-purpose user action
with exactly one consumer, so it calls notify() directly instead of
introducing an event nothing else will ever listen for.
The notify() contract
File: app/Services/NotificationService.php
public function notify(int $userId, NotificationType $notificationType, string $type, string $title, string $message, ?string $actionUrl = null): ?Notification
$userId— the recipient. Always a single user; to notify several people, callnotify()once per recipient (seeSendNotificationListener::handleIssueUpdated()'s two separate loops for actor vs. assignee).$notificationType— whichNotificationTypethis is. This is what the recipient's per-type/per-channelNotificationSettinggates on — pick an existing case if the notification is a variant of something that already exists (as this guide does, reusingIssueUpdated), or add a new one first (guide 01) if it deserves its own toggle in Account settings.$type— a UI severity string,'success'|'info'|'warning'|'error'(validated inNotificationController::update()). This is unrelated to$notificationTypeand not user-configurable — it only affects how the in-app notification might be styled. Every existing call site uses'info'.$title/$message— plain text, used verbatim as both the in-app notification's title/message and the email's subject/body (NotificationMailService::send()passes them straight intoNotificationMail). There's no per-channel wording — write one message that reads fine in both places.$actionUrl— optional; renders as the in-app "View details" link (NotificationItem.tsx) and the email's CTA button (resources/views/emails/notification.blade.php). Every existing call site builds this withroute(...), never a hand-built string.- Return value — the persisted
Notificationrow, ornullif the recipient has in-app disabled for this type (email may still have been sent either way — the two channels are independent, see below).
What actually happens per channel
notify() always does both of these, unconditionally, in this order:
- Email, via
NotificationMailService::send()— checks the recipient'sNotificationChannel::Emailsetting for this$notificationType(default: off, seeNotificationChannel::enabledByDefault()); if enabled, queues aNotificationMail(rate-limited, retried up to 5 times on transient SMTP failures — seeNotificationMail::$tries/backoff()). This is not gated byMailConfigurationService— that service only gates the project-invitation email flow and amailEnabledInertia prop used to show a "configure email" banner elsewhere. Every other notification email is attempted through Laravel's normalmail.defaultconfig regardless; if that'slogorarray, the "send" just writes to the log/an in-memory array instead of an inbox — nothing errors, so don't be surprised email notifications "work" in every environment without actually delivering anywhere until a real mailer is configured. - In-app, only if the recipient's
NotificationChannel::InAppsetting for this type is enabled (default: on) — persists aNotificationrow. Skipped entirely otherwise; no row is ever written for a disabled channel.
Step 1 — Inject NotificationService
File: app/Services/IssueService.php
public function __construct(
protected IssueRepository $issueRepository,
protected ActivityLogService $activityLogService,
protected UserService $userService,
protected NotificationService $notificationService,
) {}
Add use App\Services\NotificationService; and
use App\Enums\Notifications\NotificationType; to the file's imports.
Step 2 — Add the service method
File: app/Services/IssueService.php
public function remindAssignee(Issue $issue): void
{
if (! $issue->assignee_id) {
throw ValidationException::withMessages([
'assignee' => 'This issue has no assignee to remind.',
]);
}
$actorName = auth()->user()?->name ?? 'Someone';
$this->notificationService->notify(
$issue->assignee_id,
NotificationType::IssueUpdated,
'info',
'Reminder',
"$actorName sent you a reminder about \"$issue->title\" (#$issue->id).",
route('projects.show', $issue->project_id).'?issue='.$issue->id
);
}
Add use Illuminate\Validation\ValidationException; if not already
imported. NotificationType::IssueUpdated is reused here rather than
adding a dedicated type — a manual reminder is a variant of "something
changed about an issue you care about," and doesn't need its own
Account settings row unless you specifically want it independently
toggleable (in which case, do guide 01 first, then swap the type
here).
Step 3 — Controller action and route
File: app/Http/Controllers/IssueController.php
public function remindAssignee(Project $project, Issue $issue): RedirectResponse
{
if ($issue->project_id !== $project->id) {
throw new NotFoundHttpException;
}
$this->authorize('update', $issue);
$this->issueService->remindAssignee($issue);
return redirect()->back()->with('success', 'A reminder has been sent.');
}
This reuses IssuePolicy::update — sending a reminder is gated the
same as any other issue-management action, not worth its own
permission for a low-stakes, easily-repeatable action.
File: routes/web.php, next to the other issues/{issue} routes:
Route::post('/projects/{project}/issues/{issue}/remind', [IssueController::class, 'remindAssignee'])->name('issues.remind');
Step 4 — Tests
tests/Feature/IssueServiceTest.php— itsbeforeEachconstructsIssueServicemanually; add the new dependency:then add:beforeEach(function () { $this->issueRepository = Mockery::mock(IssueRepository::class); $this->activityLogService = Mockery::mock(ActivityLogService::class); $this->userService = Mockery::mock(UserService::class); $this->userService->shouldReceive('getUserById')->andReturnUsing(fn ($id) => User::find($id)); $this->notificationService = Mockery::mock(NotificationService::class); $this->service = new IssueService($this->issueRepository, $this->activityLogService, $this->userService, $this->notificationService); Event::fake(); });test('remindAssignee notifies the assignee', function () { $actor = User::factory()->create(['name' => 'Bob']); $this->actingAs($actor); $issue = Issue::factory()->create(['id' => 7, 'project_id' => 1, 'title' => 'Fix login', 'assignee_id' => 99]); $this->notificationService->shouldReceive('notify') ->once() ->with(99, NotificationType::IssueUpdated, 'info', 'Reminder', 'Bob sent you a reminder about "Fix login" (#7).', Mockery::type('string')); $this->service->remindAssignee($issue); }); test('remindAssignee rejects an issue with no assignee', function () { $issue = Issue::factory()->create(['assignee_id' => null]); expect(fn () => $this->service->remindAssignee($issue)) ->toThrow(ValidationException::class); });tests/Feature/IssueControllerTest.php— add a "an assignable member can remind the assignee" test (asserts a redirect + success flash) and "a member without issues.update access cannot remind the assignee" (mirrors the existingupdate-gated tests), plus "reminding through a mismatched project 404s" copying the existing pattern from other issue routes.
