osa-eg/laravel-teams-notification
Send Laravel notifications to Microsoft Teams with a simple, fluent API. This package adds a Teams channel so you can post rich message cards via webhooks, keeping your team informed straight from your Laravel app.
1. Install and publish config
composer require osa-eg/laravel-teams-notification
php artisan vendor:publish --tag=teams-notification-config
Put the webhook URL in .env rather than in code:
TEAMS_WEBHOOK_URL=https://prod-00.westus.logic.azure.com:443/workflows/.../triggers/manual/paths/invoke?...
2. Create the webhook on the Teams side first
In the target channel: Workflows → "Post to a channel when a webhook request is received" → pick channel → copy the generated URL. This is the Power Automate workflow URL, not the legacy Office 365 Connector URL — they are different products and different payloads. Getting this wrong is the most common first-hour failure.
3. Where to look first
config/teams-notification.php — default webhook, timeouts, HTTP options.src/ — the channel class and the message builder (conventionally TeamsChannel / TeamsMessage; confirm exact casing in the README, since Laravel channel packages vary).4. First use case: a five-line smoke test
Send one message on demand before wiring up notifications, so you isolate webhook problems from notification problems:
app(TeamsChannel::class)->send($webhookUrl, TeamsMessage::create()
->title('Deploy finished')
->text('api@' . config('app.env') . ' is live'));
If that posts, everything downstream is just Laravel plumbing.
Notification class (the main path). Standard Laravel channel contract — via() returns the Teams channel, toTeams() returns the message:
class DeployFailed extends Notification implements ShouldQueue
{
public function via($notifiable): array { return [TeamsChannel::class]; }
public function toTeams($notifiable): TeamsMessage
{
return TeamsMessage::create()
->title('Deploy failed')
->text("**{$this->env}** · {$this->commit}")
->facts(['Pipeline' => $this->pipeline, 'Stage' => $this->stage])
->link('Open run', $this->url);
}
}
On-demand routing — the pattern you will actually use most. Teams destinations are per-team or per-tenant, not per-User model. Storing a webhook URL on a Team/Project model and routing on demand keeps you out of the Notifiable traits:
Notification::route('teams', $project->teams_webhook_url)
->notify(new DeployFailed($deploy));
Centralise webhook resolution. Wrap destination lookup in one small service or a RouteNotificationForTeams trait so you have a single place to fall back to config('teams-notification.webhook'), skip when unset, and enforce that URLs are never interpolated from user input.
Wire it into the failure seams. These three hooks give the highest value per line of code:
App\Exceptions\Handler::report() — alert on unhandled exceptions, throttled.$schedule->command(...)->onFailure(fn () => ...) — dead cron jobs.Job::failed() / Queue::failing() — queue backlogs.Guard against noise. Add an env/config gate (TEAMS_ALERTS_ENABLED) and a cache-based throttle (Cache::add("teams:{$key}", true, 300)) inside the alerter, so a flapping service produces one message per five minutes, not one per request.
Testing. Notification::fake() + assertSentOnDemand() for the notification layer; Http::fake() for the channel layer. Assert the outgoing JSON contains your title/text rather than asserting the whole card — the card envelope is the package's concern, not yours.
The payload schema is not the legacy one. The old Office 365 Connector used @type: MessageCard with sections/potentialAction; the Power Automate workflow uses AdaptiveCard. If you copy examples from 2021-era blog posts you will get a 202 Accepted and a silent no-op, or a 400 with an opaque body. Trust the package's builder over hand-rolled arrays.
A success response does not mean a posted message. Workflow endpoints may return 202 or a 200 with an empty/text body rather than JSON. Do not chain ->json() unconditionally on the response; check status and treat non-2xx as failure.
Treat the URL as a bearer secret. Anyone with it can post to the channel. Keep it in .env or your secret store, never log it (Log::error on a failed request will happily dump the full URL in the exception message — scrub it), and rotate via a new workflow if it leaks.
Set an explicit HTTP timeout. A hanging webhook turns a failed deploy notification into a hung queue worker. Configure a short connect/total timeout in the package config, and always send alerting notifications from a queued listener or ShouldQueue notification, never inline in a request cycle.
Rate limits are real. Power Automate throttles per connection. Bulk loops (foreach ($failed as $item) notify(...)) will start dropping messages. Batch into one card — or one card per digest interval — instead of one message per row.
Card size and formatting limits. Adaptive Cards have a payload ceiling (tens of KB) and a limited markdown subset: **bold**, _italic_, links, lists. HTML and tables silently render as literal text. Long exception traces should be truncated and linked to your log viewer, not pasted.
Config caching. env() calls outside config/ return null under php artisan config:cache. Put the webhook in the config file once and read it with config() everywhere else.
Local and CI safety. Set the webhook to null (or point it at a sink channel) in .env.testing and local envs; combine with a Notification::fake() in the test suite so a stray test run cannot page the on-call channel.
Extension points worth knowing. If the built-in builder lacks a card element you need, subclass the message class and override the payload array rather than editing vendor files — and check whether the channel exposes a way to swap the message class in config. That is the clean upgrade path when the package lags the Adaptive Card schema.
How can I help you explore Laravel packages today?