symfony/slack-notifier
Symfony Slack Notifier integrates Slack messaging into Symfony's Notifier component. Send chat messages via a simple DSN with your bot token and channel, and add interactive Block Kit elements like buttons, sections, images, and dividers using SlackOptions.
Install the bridge alongside the Notifier core — the bridge does not pull in a transport implementation on its own:
composer require symfony/slack-notifier symfony/notifier symfony/http-client
Check your PHP version first. symfony/slack-notifier v8.x requires PHP >= 8.4; if you're on Laravel 11/12 with PHP 8.2 or 8.3, pin ^7.4 instead. Also watch the transitive symfony/* constraints — the bridge shares components with the framework, so a stray ^8.0 will fight Laravel's own Symfony pins.
Next, create a Slack app at api.slack.com, add the chat:write (and chat:write.public if you want to post without joining) bot scope, install it to the workspace, and copy the Bot User OAuth Token (starts with xoxb-). Invite the app into the target channel — the API will silently 403 otherwise.
The DSN is the whole configuration surface:
SLACK_DSN=slack://xoxb-...@default?channel=my-channel-name
The first real use case is almost always operational alerting: a queued job failure, a deploy notice, an exception in a scheduled task. Because this is Symfony Notifier, you build it directly rather than using Laravel's Notification facade:
use Symfony\Component\Notifier\Chatter;
use Symfony\Component\Notifier\Transport;
use Symfony\Component\Notifier\Message\ChatMessage;
$chatter = new Chatter(Transport::fromDsn(config('services.slack.dsn')));
$chatter->send(new ChatMessage('Deploy finished: ' . $sha));
Register that Chatter as a singleton in a service provider so you're not re-parsing the DSN per call — see the next section.
Bind a singleton in a service provider. The DSN parse is cheap but the transport builds an HTTP client and a stack of factories, so do it once:
// AppServiceProvider::register()
$this->app->singleton(Chatter::class, fn () => new Chatter(
Transport::fromDsn(config('services.slack.dsn'))
));
Then inject Chatter anywhere — controllers, jobs, exception handlers.
Rich messages go through SlackOptions, not the constructor. The ChatMessage text is the fallback/plain notification; everything visual lives on the options object:
$options = (new SlackOptions())
->block(new SlackHeaderBlock(':rocket: Deploy'))
->block((new SlackSectionBlock())
->field('*Env*')->field($env)
->field('*SHA*')->field(substr($sha, 0, 7)))
->block(new SlackDividerBlock())
->block((new SlackActionsBlock())
->button('Rollback', $rollbackUrl, 'danger')
->button('Logs', $logsUrl, 'primary'));
$chatter->send(new ChatMessage('Deploy', $options));
Thread related notifications with threadTs() so a multi-step pipeline doesn't spam the channel — capture the ts from the first response and pass it down:
$sent = $chatter->send(new ChatMessage('Step 1', $options));
if ($sent instanceof SlackSentMessage) {
$ts = $sent->getMessageId();
$channel = $sent->getChannelId();
}
Editing a message (progress bars, "started → done" states) requires both IDs from the original send:
use Symfony\Component\Notifier\Bridge\Slack\UpdateMessageSlackOptions;
$chatter->send(new ChatMessage(
'Deploy complete',
new UpdateMessageSlackOptions($channelId, $messageId)
));
Schedule with postAt() rather than sleeping a job — Slack holds the message:
(new SlackOptions())->postAt(new \DateTimeImmutable('+1 day'));
Always dispatch through a queued job. Slack rate-limits per channel (~1 msg/sec, and one message per channel per second during bursts), so a web request that sends three notifications will throw. Wrap sends in a job with $tries = 3 and a backoff.
Centralize the block-building. Put SlackOptions construction in a small SlackMessageBuilder class or a set of value objects per event type — otherwise block arrays leak all over the codebase and every visual tweak touches ten files.
For testability, type-hint Chatter and swap it for a fake/spy in a container binding rather than making real HTTP calls.
channel DSN parameter is strict. #my-channel and bare fabien are invalid. Use the plain channel name (my-channel) or @fabien for a DM. IDs like C0123ABCD also work and are far more stable across renames.config/services.php + env() is fine, but remember the whole DSN (token included) goes into the container's parameter bag and can leak into config:cache output and exception traces. Never log the DSN.Channel collisions. Laravel's own Slack notification channel uses services.slack.* with a webhook URL. This package uses a bot token. Don't reuse the same config key — name yours services.slack_notifier.dsn.false options were dropped on older releases (fixed in 5.4.40 / 6.4.8 / 7.0.8). If you're pinning something ancient and a markdown: false or emoji: false field quietly renders wrong, that's why. Bump to a patched 7.x.markdown and emoji are mutually exclusive on text objects — verbatim only applies to markdown text, emoji only to plain text. Slack silently ignores the wrong combination, so you get no error, just an unstyled field.400 invalid_blocks with an unhelpful message. Truncate long stack traces before building blocks.SlackSentMessage casting is required to get IDs back. Chatter::send() returns the transport-agnostic SentMessage interface; getMessageId() doesn't exist on it. Always instanceof check.Http::fake() won't intercept these calls. The notifier uses Symfony's HTTP client, not Laravel's, so your fake is bypassed and tests hit the network. Either bind a mock Chatter, or swap the transport for a NullTransport in the test environment.postAt() needs the epoch conversion to land in the future. Slack rejects timestamps in the past; a +1 day calculated against a stale cached value will fail. Also confirm the workspace's timezone assumptions — the object accepts a DateTimeInterface, so pass one with an explicit timezone.confirm dialogs only exist from v8.0 (feature added in 8.0.0-BETA1). If you're on 7.x and copy that snippet from the README, it'll be ignored.MessageOptionsInterface. If you need a block type the bridge doesn't ship (e.g. rich_text or table), implement it yourself and append via ->block() — the options class just serializes whatever blocks you give it. No subclassing of the bridge needed.composer.json and run composer why symfony/slack-notifier before bumping Laravel; a Laravel minor upgrade that raises the Symfony constraint to 8.x will silently require PHP 8.4.How can I help you explore Laravel packages today?