Search by

nowo-tech / slack-kit-bundle

HecFranco

Slack for Symfony: a hardened Web API client, signed interactions and Events API ingress with replay protection, MAC-signed button values, an interactions router with verified actors, verified account linking, a Block Kit builder and an optional Notifier transport.

Package info

github.com/nowo-tech/SlackKitBundle

Documentation

Type:symfony-bundle

pkg:composer/nowo-tech/slack-kit-bundle

Fund package maintenance!

HecFranco

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v1.0.0 2026-11-05 17:36 UTC

This package is auto-updated.

Last update: 2026-11-05 17:38:35 UTC


README

CI Packagist Version Packagist Downloads License PHP Symfony GitHub stars Coverage

⭐ Found this useful? Install from Packagist · Give it a star on GitHub so more developers can find it.

Slack for Symfony, hardened. A Web API client with pinned hosts, timeouts and typed rate-limit errors; a signed interactions and Events API ingress with a body cap, a time window and replay protection; MAC-signed button values (nsk1) so buttons posted through a leaked incoming-webhook URL cannot be forged; an interactions router whose handlers get a verified actor (a confirmed, active account) or none; verified account linking with rate limits; a Block Kit builder with mrkdwn escaping; an admin settings section with Test connection; and an optional Symfony Notifier chat transport.

πŸ“‹ Compatible with Symfony 7.4+ and 8.x (PHP 8.2+; Symfony 8.x requires PHP 8.4+)

FrankenPHP worker mode friendly. Every service is stateless (final readonly) or implements ResetInterface; credentials are read from the installation store on each call and never memoised across requests; the only per-request buffer (deferred Slack answers) is flushed on kernel.terminate and reset by kernel.reset. See docs/FRANKENPHP-WORKER-AUDIT.md.

Table of contents

Features

  • βœ… Web API client β€” auth.test, conversations.open, chat.postMessage / chat.update / chat.delete, chat.postEphemeral, users.lookupByEmail (opt-in). Constant base URL https://slack.com/api/, token only in the Authorization header, no redirects, 5 s idle / 10 s total by default, 1 MiB response cap.
  • βœ… Typed errors and rate limits β€” SlackApiException, SlackAuthException, SlackScopeException, SlackRateLimitedException (Retry-After clamped). Never sleeps; a 429 is remembered per installation and method so other workers do not hammer Slack; optional Messenger middleware retries after Retry-After.
  • βœ… Signed ingress β€” POST /slack/interactions and POST /slack/events (paths configurable, per-app variants /{app}): body cap before parsing, HMAC v0 with hash_equals, ≀ 300 s window on a PSR-20 clock, atomic signature replay memory on symfony/lock (allowlist of expiring shared stores such as Redis or DBAL; local and session-scoped stores fail the production build), event_id de-duplication, workspace and app id checks, fixed refusal bodies. Clicks verified with a host-resolved (per-destination) secret never get an actor unless you opt in.
  • βœ… nsk1 button values β€” nsk1.<payload>.<mac>: canonical JSON, HKDF-SHA256 key from kernel.secret, 128-bit MAC bound to the action_id, expiry, optional workspace, verify-only keys after a secret rotation. Byte-compatible with symfony-beacon 1.122.1 (the contract's three test vectors are in the test suite).
  • βœ… Interactions router β€” #[AsSlackActionHandler(action: 'acme.issue.resolve')] services; handlers get a verified SlackInteraction with a resolved SlackActor or none; ephemeral answers and message replacement run after the response.
  • βœ… Verified account linking β€” typed Slack id or Find me by e-mail, confirmation DM with a signed button, verification only when the clicker is the claimed member; squatting released after 24 h or by a proven claim; four rate limiters; a UiKit account card with CSRF-protected actions.
  • βœ… Credentials β€” installation store interface; InstanceSettings-backed store (encrypted bot token and signing secret, admin section with Test connection) or in-memory store; secrets wrapped in SlackSecret (never dumped, serialised or logged); transport errors keep only a redacted copy of the HttpClient message (SecretRedactor); incoming webhooks go through a logger-free, untraced HttpClient. A shipped contract test case checks host stores.
  • βœ… Block Kit builder β€” sections, headers, context, actions, buttons, link buttons, fields; Slack's limits enforced (truncate text, refuse structure overflows); Mrkdwn::escape() by default, fallback text and Notifier subjects included.
  • βœ… Optional Notifier transport β€” nowo-slack://default?app=default (no secret in the DSN).
  • βœ… Translations β€” NowoSlackKitBundle domain in en, es, fr, de, it, pt and nl.
  • βœ… Fakes only β€” tests, fixtures and the demo use an in-app fake Slack; no real workspace, token or secret.

Installation

composer require nowo-tech/slack-kit-bundle

Register the bundle (Flex does it), import the routes and open the ingress paths in the firewall:

# config/routes/nowo_slack_kit.yaml
nowo_slack_kit:
    resource: .
    type: nowo_slack_kit
# config/packages/security.yaml
security:
    firewalls:
        slack:
            pattern: ^/slack/(interactions|events)
            security: false

Full guide, including the Slack app manifest with placeholder URLs and the minimum scopes: docs/INSTALLATION.md.

Quick start

use Nowo\SlackKitBundle\Api\SlackApiClientInterface;
use Nowo\SlackKitBundle\Api\SlackMessage;
use Nowo\SlackKitBundle\BlockKit\Blocks;
use Nowo\SlackKitBundle\Credentials\SlackInstallationKey;
use Nowo\SlackKitBundle\Id\SlackUserId;
use Nowo\SlackKitBundle\Interaction\ButtonValueCodec;

public function __construct(private SlackApiClientInterface $slack, private ButtonValueCodec $codec) {}

$installation = SlackInstallationKey::default();
$channel      = $this->slack->openDirectMessage($installation, SlackUserId::fromString('U00000001'));
$value        = $this->codec->sign('acme.task.done', ['task' => '42']);

$this->slack->postMessage($installation, $channel, new SlackMessage('Task 42 is due', [
    Blocks::section('Task *42* is due'),
    Blocks::actions([Blocks::button('Mark done', 'acme.task.done', $value, 'primary')]),
]));
use Nowo\SlackKitBundle\Interaction\AsSlackActionHandler;
use Nowo\SlackKitBundle\Interaction\SlackActionHandlerInterface;
use Nowo\SlackKitBundle\Interaction\SlackInteraction;
use Nowo\SlackKitBundle\Interaction\SlackInteractionReply;

#[AsSlackActionHandler(action: 'acme.task.done')]
final readonly class TaskDoneHandler implements SlackActionHandlerInterface
{
    public function handle(SlackInteraction $interaction): SlackInteractionReply
    {
        // $interaction->actor->accountId is a verified, active account (requiresActor: true by default)
        return SlackInteractionReply::done();
    }
}

Open the admin settings section Slack (InstanceSettings), paste the bot token and the signing secret, press Test connection (it fills the workspace id) and paste the Interactivity URL shown in the section intro into the Slack app. More in docs/USAGE.md.

Requirements

  • PHP 8.2+ (>=8.2 <8.6; Symfony 8.x requires PHP 8.4+)
  • Symfony 7.4, 8.0 or 8.1 (FrameworkBundle, HttpClient, Lock, RateLimiter, Routing, Form, Validator, Security CSRF, Clock)
  • In production, an expiring shared lock store for the replay memory, ideally a dedicated framework.lock.resources.slack_kit resource (Redis, or a DBAL / PDO DSN without +advisory); a random kernel.secret of at least 32 bytes
  • nowo-tech/ui-kit-bundle ^2.27, nowo-tech/form-kit-bundle ^2.17
  • Suggested: nowo-tech/instance-settings-bundle (bundled credential store and settings section), symfony/security-bundle (account link routes), symfony/messenger (async Events API, rate-limit retries), symfony/notifier (chat transport)

Configuration

Every key, its default and its validation: docs/CONFIGURATION.md. A typical single-workspace setup:

# config/packages/framework.yaml (production): a dedicated lock resource, the app's own locks unchanged
framework:
    lock:
        resources:
            default: ['%env(LOCK_DSN)%']
            slack_kit: ['%env(SLACK_KIT_LOCK_DSN)%']  # redis://… or a DBAL / PDO DSN (no +advisory)

# config/packages/nowo_slack_kit.yaml
nowo_slack_kit:
    credentials:
        store: instance_settings      # bot token + signing secret in the encrypted "slack" section
    ingress:
        events_enabled: true          # app_uninstalled / tokens_revoked
        replay_lock_factory: lock.slack_kit.factory   # atomic replay memory on the dedicated resource
    interactions:
        value_ttl: P30D
        previous_secrets: []          # '%env(OLD_APP_SECRET)%' after a kernel.secret rotation
    linking:
        enabled: true
        identity_store: App\Slack\UserSlackIdentityStore
        account_resolver: App\Slack\CurrentUserAccountResolver

Interim decisions shared with symfony-beacon

These match symfony-beacon 1.122.1, so its adoption of the bundle changes nothing for buttons already posted:

  1. Link-confirm values may omit the nonce n (Beacon has no nonce store). The bundle binds each confirmation to the claim with c (claim time). Signed confirmations without c are accepted only while interactions.legacy_until is in the future; that leaves a bounded replay window (same account, same Slack id, at most 24 h) described in docs/SECURITY.md.
  2. t (workspace) is in a signed value only when the team id is known, and omitted otherwise.
  3. Final keys nowo_slack_kit.interactions.{legacy_until, previous_secrets, value_ttl}. legacy_until is a strict UTC instant (2026-12-05T00:00:00Z).

The REQ-SEC-004 audit (appendix FS) fixes are listed in docs/CHANGELOG.md.

Details and the remaining differences from contract v1.1: docs/CHANGELOG.md.

Demo

  • demo/symfony8 β€” Symfony 8.1 on FrankenPHP (worker mode) with MySQL; host port 8086. The Slack settings section, the account link card, signed buttons you can "click" through the real ingress, and an in-app fake Slack that answers the Web API and records every call. Nothing leaves the container.

Demo home: account link card and fake Slack log

Slack settings section after Test connection

make -C demo up-symfony8          # http://localhost:8086
make test-e2e                     # Playwright e2e against the running demo
make demo-smoke                   # boot, HTTP 200, down (REQ-TEST-011)

See docs/DEMO-FRANKENPHP.md.

Development

make up
make install
make test
make cs-check
make phpstan
make igor
make test-coverage-100
make validate-translations
make release-check

Documentation

Additional documentation

Tests and coverage

  • Tests: PHPUnit β€” unit tests (client on MockHttpClient for every error row, signature verifier, replay guard, ingress order of checks, nsk1 codec with the contract test vectors and negative checks, legacy policy, router, actor resolver, account linker transitions and limiters, secrets and redaction, Block Kit limits, stores, DI and build-time refusals, Notifier transport, Messenger middleware) and kernel integration tests (settings section with CSRF and Test connection, account card end to end with a signed click on the ingress, ingress routes); Playwright e2e on the demo.
  • PHP: coverage gate in CI (composer test-coverage-100), target 100 %.
  • Fakes only: bot token xoxb-test-not-a-real-token, team T00000000, users U00000001…, e-mails @example.test.
  • TS/JS: N/A Β· Python: N/A

License and author

MIT Β· Nowo.tech Β· HΓ©ctor Franco Aceituno