helgesverre / slack-bolt
A PHP-native Slack application framework built on Tempest.
Requires
- php: ^8.5
- amphp/amp: ^3.1
- amphp/http-client: ^5.3
- amphp/websocket-client: ^2.0
- composer/semver: ^3.4
- guzzlehttp/guzzle: ^7.8.2
- guzzlehttp/psr7: ^2.6
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/log: ^3.0
- revolt/event-loop: ^1.0
- tempest/framework: ^3.20.0
Requires (Dev)
- carthage-software/mago: 1.50.0
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Build Slack apps in modern PHP: typed handlers, immutable Block Kit builders, and explicit durable jobs. Built on PHP 8.5+ and Tempest 3, with inspiration from Bolt for JavaScript and Laravel's coding style.
Experimental. Install helgesverre/slack-bolt to get the latest compatible release. The PHP namespace is Thunder\Bolt. This is an independent framework; full Bolt JS compatibility remains a work in progress. Parity audit · Changes · Upgrading.
Explore the framework
| Build with | What it looks like |
|---|---|
| Installation | Composer package and bolt new project scaffolding |
| Slack configuration | HTTP or Socket Mode; static token or OAuth |
| Handlers | Discovered attributes, typed payloads, or fluent registration |
| Messages and views | Immutable Block Kit, modals, and App Home builders |
| Interactions | Buttons, shortcuts, select options, and form validation |
| Web API client | Typed requests/responses, pagination, uploads, and bounded retries |
| Middleware and errors | Delivery context and an explicit failure policy |
| OAuth installations | State validation, installation stores, and bot/user token rotation |
| Conversation state | Channel/thread snapshots with file, SQLite, or memory adapters |
| Durable jobs | Named jobs, deduplication, recovery, retries, and cancellation |
| Assistants and streaming | Thread lifecycle, suggested prompts, sessions, and streaming replies |
| Workflow functions | Typed inputs and explicit complete/fail helpers |
| Work Objects | Native entity previews and application-owned comments |
| Testing your app | Signed fixtures, fake Slack, and reply/job assertions |
| CLI and operations | Generators, diagnostics, worker status, and deployment |
| Playground | A shared pet, terminal, AI streaming, and an interactive tour |
Installation
Requirements
| Dependency | When you need it |
|---|---|
| PHP 8.5+ and Composer 2 | Every application |
pdo_sqlite |
Durable jobs or SQLite conversation storage |
pcntl |
Signal handling for long-running workers where supported |
| Node 22+ | Contributor tooling only; not the PHP runtime |
Add the package
composer require helgesverre/slack-bolt
Create a new application
Install the CLI, then generate an application backed by Packagist:
composer global require helgesverre/slack-bolt
bolt new my-bot --no-interaction
cd my-bot
composer install
cp .env.example .env
Put Composer's global vendor bin directory on PATH if needed: composer global config bin-dir --absolute.
| Scaffold option | Values | Default |
|---|---|---|
--transport |
http, socket |
http |
--auth |
token, oauth |
token |
--source |
packagist, path, vcs |
packagist |
--repository |
HTTPS/SSH URL or absolute local Git repository | Required for vcs |
--version |
Supported Composer constraint, or dev-main |
CLI's release constraint for Packagist; dev-main for Git |
Generated apps use Packagist without a custom Composer repository and run independently of your CLI installation. Use --source=path for framework development, or --source=vcs --repository=… for an explicit Git source. Existing directories are never overwritten. Full getting-started guide.
Slack configuration
Import the generated slack-manifest.yaml into a development Slack app, install it, and invite it to a test channel. The starter subscribes to app_mention with app_mentions:read and chat:write.
Choose a transport
| Delivery | Application credentials | Slack setting | Run |
|---|---|---|---|
| HTTP | Bot token and signing secret | Public HTTPS request URLs ending in /slack/events |
php bolt serve |
| Socket Mode | Bot token and app-level token | Enable Socket Mode; app-level scope connections:write |
php bolt socket:serve |
These rows show ordinary single-workspace bot delivery. OAuth can replace the static bot token with installation authorization; its browser routes still use HTTP with either transport.
For HTTP, configure .env:
SLACK_BOT_TOKEN=xoxb-replace-me SLACK_SIGNING_SECRET=replace-me
For Socket Mode, add an app-level token:
SLACK_APP_TOKEN=xapp-replace-me
Verify and run
php bolt doctor
php bolt auth:verify
php bolt serve
# Or: php bolt socket:serve --verify-token
doctor checks local configuration; auth:verify explicitly contacts Slack to verify a static token. For HTTP self-message filtering without request-time lookup, copy the returned user_id and bot_id into SLACK_BOT_USER_ID and SLACK_BOT_ID.
serve is a development server. HTTP development needs an HTTPS tunnel; production serves public/index.php through your PHP host. Socket Mode needs no public event URL. Event subscriptions, interactivity, and slash commands still need their own Slack-side configuration. Follow the Slack setup guide with screenshots.
Handlers
Place handler classes under your application's app/ directory. Tempest discovers attributes and injects typed payloads and delivery capabilities. Ordinary application services belong in constructors.
Listen for an event
use Thunder\Bolt\Attributes\Event; use Thunder\Bolt\Payloads\Events\AppMention; use Thunder\Bolt\Reply; final readonly class GreetMention { #[Event('app_mention')] public function __invoke(AppMention $event, Reply $reply): void { $reply->inThread("Hello <@{$event->userId}>!"); } }
Handle a slash command
use Thunder\Bolt\Attributes\SlashCommand; use Thunder\Bolt\Responses\CommandResponse; final readonly class Ping { #[SlashCommand('/ping')] public function __invoke(): CommandResponse { return CommandResponse::ephemeral('Pong.'); } }
Choose a listener type
| Attribute | Payload | Initial response |
|---|---|---|
#[Event('app_mention')] |
Payloads\Events\AppMention |
None |
#[SlashCommand('/ping')] |
Payloads\SlashCommand |
CommandResponse |
#[Action('approve_deployment')] |
Payloads\Action |
ActionResponse |
#[Shortcut('open_deployment')] |
Payloads\Shortcut |
ShortcutResponse |
#[Options('search_assignees')] |
Payloads\Options |
OptionsResponse |
#[View('approve_deployment')] |
Payloads\View |
ViewResponse |
Known events have native payload properties such as userId and channelId. App Home, channel lifecycle, and message-metadata events also have native payloads. Unknown event types use the generic event payload; raw preserves original Slack data. Typed-event catalog.
Handlers execute inline. Commands/interactions must return exactly one response of the correct family across matching listeners, within Slack's three-second deadline. Returning does not flush an acknowledgement early. Queue slow work explicitly.
Register fluently
An embedded application can register handlers on an already configured Thunder\Bolt\App:
use Thunder\Bolt\Payloads\Events\AppMention; use Thunder\Bolt\Reply; use Thunder\Bolt\Responses\CommandResponse; $app ->event('app_mention', static function (AppMention $event, Reply $reply): void { $reply->inThread("Hello <@{$event->userId}>."); }) ->command('/ping', static fn (): CommandResponse => CommandResponse::ephemeral('Pong.'));
Attribute and fluent registration share the same dispatcher. Choose one registration route for each handler; equivalent duplicate registrations fail at boot. Handler and middleware guide.
Messages and views
Factories require essential values. Fluent methods add optional fields. Builders are immutable: retain the returned value when adding an option.
Compose a Block Kit message
use Thunder\Bolt\BlockKit\{Actions, Button, Section}; use Thunder\Bolt\Messages\Message; $message = Message::blocks( Section::markdown('*Deployment ready*'), Actions::make( Button::make('Approve', 'approve_deployment')->primary(), ), )->fallbackText('Deployment ready for approval.'); $reply->inChannel($message);
Build a modal
use Thunder\Bolt\BlockKit\{Input, PlainTextInput}; use Thunder\Bolt\Views\Modal; $modal = Modal::make('Approve deployment', Input::make('Reason', PlainTextInput::make('reason')->multiline()) ->id('reason'), )->callbackId('approve_deployment')->submit('Approve');
In a shortcut handler, use its short-lived triggerId to open the modal and return the matching acknowledgement:
use Thunder\Bolt\Responses\ShortcutResponse; $client->views()->open([ 'trigger_id' => $shortcut->triggerId, 'view' => $modal->toArray(), ]); return ShortcutResponse::empty();
Publish App Home
use Thunder\Bolt\BlockKit\Section; use Thunder\Bolt\Views\Home; $home = Home::make( Section::markdown('*Workspace dashboard*'), Section::markdown('No pending approvals.'), ); $client->views()->publish([ 'user_id' => 'U123', 'view' => $home->toArray(), ]);
| Builder family | Examples |
|---|---|
| Layout | Sections, actions, context, dividers, containers and multi-column layouts |
| Data | Tables, interactive data tables, pie/bar/area/line charts and cards |
| Controls | Buttons, inputs, date/time pickers, selectors, workflow controls |
| Rich content | Rich text, images, files, message metadata, legacy attachments |
| Views | Modals and App Home |
The Block Kit catalog lists all 22 native block builders, element factories and composition values, with examples for layouts, data tables and charts. Raw arrays remain an escape hatch for additional Slack fields.
Interactions
Respond to a button
use Thunder\Bolt\Attributes\Action as OnAction; use Thunder\Bolt\Reply; use Thunder\Bolt\Responses\ActionResponse; final readonly class ApproveDeployment { #[OnAction('approve_deployment')] public function __invoke(Reply $reply): ActionResponse { $reply->replaceOriginal('Deployment approved.'); return ActionResponse::empty(); } }
replaceOriginal() uses the incoming response URL. Some interactions, including modal actions, have no response URL; use only reply helpers supported by that delivery's context.
Validate a modal submission
use Thunder\Bolt\Attributes\View as OnView; use Thunder\Bolt\Payloads\View; use Thunder\Bolt\Responses\ViewResponse; final readonly class ValidateApproval { #[OnView('approve_deployment')] public function __invoke(View $view): ViewResponse { $reason = $view->value('reason', 'reason'); if (!is_string($reason) || trim($reason) === '') { return ViewResponse::errors(['reason' => 'Enter a reason.']); } return ViewResponse::empty(); } }
Errors address block IDs. Typed form readers also support $view->form()->require('reason', 'reason')->text(); action inputs expose selections through $action->input().
Load external select options
use Thunder\Bolt\Attributes\Options as LoadOptions; use Thunder\Bolt\BlockKit\Option; use Thunder\Bolt\Payloads\Options; use Thunder\Bolt\Responses\OptionsResponse; final readonly class SearchAssignees { #[LoadOptions('search_assignees')] public function __invoke(Options $request): OptionsResponse { // Use $request->value to search your application's people directory. return OptionsResponse::options( Option::text('Ada', 'ada'), Option::text('Linus', 'linus'), ); } }
| Reply capability | Purpose |
|---|---|
inChannel() |
Post to the delivery's channel |
inThread() |
Post under its known thread root |
ephemeral() |
Send a channel/user-scoped ephemeral reply |
respond() |
Use the delivery's response URL |
replaceOriginal() / deleteOriginal() |
Update or delete through that response URL |
Missing channel, user, thread, or response-URL context fails explicitly. Global shortcuts do not invent a channel. Interactivity reference covers compound selectors, regex captures, view closures, and legacy forms.
Web API client
Inject Thunder\Bolt\WebApi\Client into a handler constructor. OAuth deliveries resolve an installation-scoped client; the application-wide client's credentials remain unchanged.
Typed requests and responses
use Thunder\Bolt\Messages\Message; use Thunder\Bolt\WebApi\Requests\ChatPostMessage; $result = $client->chat()->postMessage( new ChatPostMessage('C123', Message::text('Hello from PHP')), ); $messageTimestamp = $result->ts;
Arrays and Slack's method hierarchy
$client->chat()->postMessage([ 'channel' => 'C123', 'text' => 'A forward-compatible payload.', ]); $client->admin()->conversations()->archive(['channel_id' => 'C123']);
Cursor pagination
foreach ($client->paginate('conversations.list', ['limit' => 100]) as $page) { foreach ($page->get('channels', []) as $channel) { // Process this page; stop iteration to avoid fetching more pages. } }
Upload a file
use Thunder\Bolt\WebApi\Uploads\Upload; $client->upload( Upload::path('report.pdf') ->title('Monthly report') ->toChannel('C123') ->comment('The report is ready.'), );
uploadMany() handles batches. Upload failures expose partial progress where available; a batch is not an atomic Slack transaction.
Opt into retries in background work
use Thunder\Bolt\WebApi\RetryPolicy; $background = $client->retrying( RetryPolicy::rateLimits(maxRetries: 3, maxTotalWait: 60), ); $result = $background->conversations()->list(['limit' => 100]);
| Client capability | Behavior |
|---|---|
| Typed models | 273 request/response pairs; some nested/polymorphic values remain raw |
| Unknown fields | Available through response data and get() |
| Default retries | One send; no automatic replay |
| Rate-limit retries | Explicit bounded policy, respecting Retry-After |
| Transport | Replaceable PSR-18 client for timeouts and recorded tests |
| Uploads | File paths, bytes, streams, and multiple uploads |
Use waits/retries outside the acknowledgement path. Transient-error retries can repeat an already-successful write; choose them only for safe operations. OAuth exchanges, function completion, response URLs, and file-content transfers remain single-attempt. Web API client guide.
Middleware and errors
Attach delivery-local context
use Closure; use Thunder\Bolt\Invocation\Delivery; use Thunder\Bolt\Middleware\Middleware; final readonly class RequestContext implements Middleware { public function handle(Delivery $delivery, Closure $next): mixed { $delivery->context->set('request_id', bin2hex(random_bytes(8))); return $next($delivery); } } $app->use(new RequestContext());
Handlers can inject Thunder\Bolt\Context and read get(), require(), or has(). Transport context factories can enrich it after authorization, before middleware. Context factory examples.
Compose message filters
use Thunder\Bolt\Middleware\MessageFilter; use Thunder\Bolt\Payloads\Events\Message; use Thunder\Bolt\Reply; $app->message('', MessageFilter::directMention(), static function (Message $message, Reply $reply): void { $reply->inThread('You mentioned me.'); }, );
directMention() requires a known authorized bot user ID. MessageFilter::subtype('file_share') selects a specific message subtype.
Decide how failures are handled
use Thunder\Bolt\Errors\{ErrorHandling, FailureContext}; $app->onError(static function (FailureContext $failure): ErrorHandling { error_log(json_encode($failure->summary(), JSON_THROW_ON_ERROR)); return ErrorHandling::Handled; });
The failure context includes the original error, processing stage, listener, delivery, and buffered acknowledgement state. summary() omits payloads and credentials. Return Rethrow to propagate the error instead. Building apps.
OAuth installations
Use a static bot token for a single workspace, or enable OAuth for installation-specific credentials:
SLACK_CLIENT_ID=replace-me SLACK_CLIENT_SECRET=replace-me SLACK_OAUTH_REDIRECT_URI=https://your-app.example/slack/oauth_redirect SLACK_BOT_SCOPES=app_mentions:read,chat:write,commands
| Route or component | Responsibility |
|---|---|
GET /slack/install |
Create state, bind the browser cookie, redirect to Slack |
GET /slack/oauth_redirect |
Validate/consume state, exchange code, persist installation |
| Installation store | Separate workspace/Grid and bot/user grants |
| Delivery authorizer | Select the installation before invoking handlers |
| Token rotator | Refresh expiring OAuth bot/user grants under the store lock |
Register the exact HTTPS callback in Slack. The default private file stores coordinate workers sharing that filesystem; multiple independent hosts need shared store implementations. State is one-time and server-backed. App-level xapp credentials are separate and are not refreshed by the OAuth token rotator.
Handlers can inspect the selected installation without capturing credentials globally:
use Thunder\Bolt\Attributes\Event; use Thunder\Bolt\Authorization\Authorization; use Thunder\Bolt\Reply; final readonly class InstallationGreeting { #[Event('app_mention')] public function __invoke(Authorization $authorization, Reply $reply): void { $reply->inThread('Hello workspace '.$authorization->teamId); } }
Per-install scopes, workspace hints, server-side metadata, installation hooks, and host-owned routes are supported. OAuth guide.
Conversation state
Choose the right lifetime
| Capability | Scope | Persists across deliveries? |
|---|---|---|
Context |
Current delivery or job attempt | No |
ConversationContext |
Installation and channel | With a durable adapter |
ThreadContext |
Installation, channel, and thread root | With a durable adapter |
| Assistant focus context | Slack assistant thread | With its configured context store |
| Named job data | One durable execution | In the job store |
Update thread state atomically
use Thunder\Bolt\Attributes\Event; use Thunder\Bolt\Conversation\ThreadContext; use Thunder\Bolt\Payloads\Events\AppMention; final readonly class CountMentions { #[Event('app_mention')] public function __invoke(AppMention $event, ThreadContext $thread): void { $thread->update(static fn (mixed $state): array => [ 'mentions' => ($state['mentions'] ?? 0) + 1, ]); } }
value() reads a delivery snapshot; replace(), update(), and delete() persist explicit changes. Use update() for concurrent counters. It does not deduplicate Slack retries automatically.
Select storage
SLACK_CONTEXT_STORE=sqlite SLACK_CONTEXT_STORE_PATH=/srv/my-bot/context.sqlite
| Driver | Suitable for |
|---|---|
file |
Default generated app; processes sharing persistent files |
sqlite |
Local durable state with transactional updates |
memory |
Tests or one long-lived application instance |
disabled |
Apps that do not need conversation capabilities |
Keep data outside public/. Memory is not shared across workers or ordinary HTTP requests. Custom adapters implement ConversationStore. Conversation and thread storage.
Durable jobs
Queue named data, not a live delivery
use Tempest\CommandBus\CommandHandler; use Thunder\Bolt\Attributes\Event; use Thunder\Bolt\Jobs; use Thunder\Bolt\Jobs\{Job, JobExecution}; use Thunder\Bolt\Payloads\Events\AppMention; use Thunder\Bolt\Reply; final readonly class PublishNote implements Job { public function __construct(public string $text) {} } final readonly class QueueNote { #[Event('app_mention')] public function __invoke(AppMention $event, Jobs $jobs): void { $jobs->dispatch(new PublishNote($event->text)); } } final readonly class PublishNoteHandler { public function __construct(private Reply $reply, private JobExecution $execution) {} #[CommandHandler] public function handle(PublishNote $job): void { $this->execution->checkpoint(); $this->reply->inThread($job->text); } }
php bolt jobs:work
Dispatch commits to SQLite before returning. The worker resolves current installation credentials and fresh context for each attempt. Default duplicate keys use the Events API event ID, partitioned by installation and job class. Commands/interactions need an explicit stable key: when no delivery event ID exists.
Understand execution outcomes
| Concern | Default or application responsibility |
|---|---|
| Attempts | One; retries must be explicitly enabled |
| Safe retries | Jobs\RetryPolicy::idempotent(maxAttempts: 3) promises the whole handler is repeatable |
| Abandoned unsafe attempt | Marked uncertain for inspection |
| Cancellation | Cooperative JobExecution::checkpoint() between steps |
| Lease renewal | Call heartbeat() during long work |
| Retention | Prune terminal payloads/history; compact deduplication receipts remain |
| Side effects | No automatic rollback or exactly-once external delivery guarantee |
The default database is .thunder-bolt/jobs/queue.sqlite; SLACK_JOB_STORE_PATH selects another private persistent path. Do not queue live clients, delivery objects, or workflow tokens. Durable operations covers retry policy, recovery, cancellation, and upgrades.
Assistants and streaming
Register the thread lifecycle
Return this from config/assistant.config.php in a generated app:
use App\Listeners\WorkspaceAssistant; use Thunder\Bolt\Assistant; return Assistant::make() ->threadStarted([WorkspaceAssistant::class, 'started']) ->userMessage([WorkspaceAssistant::class, 'message']);
A thread-start handler can greet the user and offer suggested prompts:
use Thunder\Bolt\Assistant\{SuggestedPrompt, Thread}; use Thunder\Bolt\Payloads\Events\AssistantThreadStarted; final readonly class WorkspaceAssistant { public function started(AssistantThreadStarted $event, Thread $thread): void { $thread->saveContext(); $thread->say('What are we working on?'); $thread->setSuggestedPrompts([ new SuggestedPrompt('Explain a change', 'Explain the latest change.'), ]); } public function message(Thread $thread): void { $thread->say('Message received. Add your application logic here.'); } }
Stream text into a thread
Given a thread and text fragments from your model provider, run slow model/tool work in a durable worker:
use Thunder\Bolt\Assistant\AgentSessionStatus; $stream = $thread->sayStream(); foreach ($textFragments as $fragment) { $stream->append($fragment); } $stream->finish(AgentSessionStatus::Suspended);
The stream needs at least one nonempty fragment before finishing. chunks() also accepts Slack streaming chunks for tool/task progress.
| Thread helper | Use |
|---|---|
say() / sayStream() |
Replies and streamed output pinned to the thread |
saveContext() / context() |
Legacy assistant focus context, separate from chat history |
setSuggestedPrompts() |
Starter prompts |
session()->processing() / active() |
Modern session status |
session()->rename() |
Modern session title |
setStatus() / setTitle() |
Retained legacy assistant-thread APIs |
Modern session APIs require the current Slack Agents capability; subscribing to legacy assistant_thread_* events alone does not enable them. Use Assistant::forAgentView() for typed current focus, Messages-tab opening and root DM routing. Enable Agent View in the target workspace first; see the Agent View guide. A Stop event does not cancel arbitrary PHP code: the app must cancel its jobs and check cancellation during work.
Task progress can share the text stream:
use Thunder\Bolt\Assistant\Streaming\{TaskStatus, TaskUpdate}; $task = TaskUpdate::make('search', 'Read the reference'); $stream = $thread->sayStream(); $stream->chunks([$task])->append('Checking the API.'); $stream->chunks([$task->status(TaskStatus::Complete)->output('Reference found.')]); $stream->append('Here is what it supports.')->stop();
Typed chunks also cover markdown, plans and Block Kit. Raw arrays remain available. Streaming guide and limits.
Generate a modern listener with php bolt make:assistant WorkspaceAgent --agent-view. Modern configuration uses the same application config seam:
use App\Listeners\WorkspaceAgent; use Thunder\Bolt\Assistant; return Assistant::forAgentView() ->opened([WorkspaceAgent::class, 'opened']) ->appContextChanged([WorkspaceAgent::class, 'contextChanged']) ->userMessage([WorkspaceAgent::class, 'message']);
AppContext is injectable per delivery. Missing focus becomes empty context; it is never restored from an older selection. Only messages have a rooted Thread. Legacy Assistant::make() remains available.
Model execution belongs in application code. The demo uses OpenAI Responses through the thread/worker boundary; the framework does not require a model provider. Advanced Slack features · AI playground.
Workflow functions
Map function inputs to a readonly DTO and explicitly complete the execution:
use Thunder\Bolt\Attributes\{FromInputs, SlackFunction}; use Thunder\Bolt\Functions\Execution; final readonly class FormatInputs { public function __construct(public string $message, public bool $uppercase = false) {} } final readonly class FormatMessage { #[SlackFunction('format_message')] public function __invoke(#[FromInputs] FormatInputs $inputs, Execution $execution): void { $text = $inputs->uppercase ? mb_strtoupper($inputs->message) : $inputs->message; $execution->complete(['message' => $text]); } }
| Capability | Behavior |
|---|---|
| Typed inputs | Strict mapping, defaults, nullable fields, nested DTOs, backed enums |
complete() / fail() |
One terminal response per delivery |
| Workflow credential | Delivery-scoped by default; never pooled globally |
| Slack configuration | Remote function runtime, callback definition, function_executed, eligible workspace |
The transport acknowledgement does not complete the function. Completion expires its workflow credential, so make it the last Slack call using that token. A failed terminal request has an uncertain outcome; do not automatically send the opposite result. Custom-function guide.
Work Objects
Publish external domain records as Slack-native previews:
use Thunder\Bolt\Messages\Message; use Thunder\Bolt\WorkObjects\{Entity, EntityField, ExternalReference}; $task = Entity::task( new ExternalReference('TASK-42', 'task'), 'https://example.com/tasks/42', 'Ship the PHP integration', )->field('status', EntityField::status('In progress', 'blue'))->comments(0); $client->chat()->postMessage([ 'channel' => 'C123', ...Message::text('Ready for review')->entities($task)->toArray(), ]);
| Layer | Responsibility |
|---|---|
Entity and fields |
Typed preview data |
$client->workObjects() |
Publish details, unfurls, and comment results |
CommentProvider |
Application-owned persistence |
| Authorization callback | Enforce read/post/edit/delete access on every operation |
Slack feature availability, entity types, event subscriptions, and any unfurl domains/scopes must be configured separately. Work Objects guide · Runnable comment demo.
Testing your app
Exercise real handlers without Slack credentials or network calls:
use App\Listeners\GreetOnMention; use Thunder\Bolt\Testing\BoltTest; $bolt = BoltTest::make()->listeners(GreetOnMention::class); $bolt->mention('hello', user: 'U123', channel: 'C123') ->assertAcknowledged() ->assertThreadReply('Hello <@U123>!') ->assertNoJobsQueued();
| Test capability | Interface |
|---|---|
| Commands and interactions | command(), interaction() |
| Web API responses | $bolt->slack->respondsWith('users.info', $response) |
| Application dependencies | bind($fake, as: Reports::class) |
| Real SQLite intake | BoltTest::make(jobs: true)->jobHandlers(PublishNoteHandler::class) |
| Assertions | Thread/channel/ephemeral replies, acknowledgements, API calls, queued jobs |
Results are scoped to one delivery. The harness uses the real HTTP receiver, memory conversation state, and optional in-memory SQLite; it does not load .env, boot the application kernel, or execute queued jobs. It has no PHPUnit/Pest dependency. Public testing toolkit.
CLI and operations
Run project commands as php bolt … from the generated app.
Generate and inspect
| Command | Purpose |
|---|---|
make:listener Greeting --event=app_mention |
Typed event listener |
make:listener Approve --action=approve_deployment |
Button/action listener |
make:listener Form --view=approve_deployment |
Modal handler |
make:listener Format --function=format_message |
Workflow-function handler |
make:assistant WorkspaceAssistant |
Legacy assistant listener and configuration |
make:assistant WorkspaceAgent --agent-view |
Modern Agent View, typed streaming and Stop handler |
make:job PublishNote |
Named job and handler |
listener:list --format=json |
Discovered listeners |
completion:show --shell=zsh |
Project shell-completion script |
Generators refuse to overwrite existing files. They write local code; they do not configure or install the Slack app.
Run and diagnose
| Command | Purpose |
|---|---|
serve / socket:serve |
HTTP development server or Socket Mode worker |
auth:verify |
Explicit live static-token identity check |
doctor --format=json |
Local profile, credentials/placeholders, and storage prerequisites |
status --format=json --require=socket,jobs |
Read worker observations and existing queue state |
jobs:work |
Run durable jobs in a separate process |
jobs:list / jobs:show JOB_ID |
Inspect executions and attempt history |
jobs:cancel JOB_ID |
Request cancellation |
jobs:recover |
Apply lease-recovery policy |
jobs:retry JOB_ID --allow-repeat |
Explicitly permit another attempt |
jobs:migrate |
Apply supported additive schema migrations |
jobs:prune --before=30d --dry-run |
Preview terminal payload/history retention |
doctor does not prove token validity, successful OAuth installation, or a running worker. status is read-only: it does not contact Slack or initialize/recover a queue. Use a process supervisor for workers and persistent private storage for state. Before upgrades, stop intake/workers and back up durable state consistently.
The Docker Compose example demonstrates separate intake/workers, non-root execution, health checks, persistent volumes, and graceful restarts.
Playground
The hello-bolt application demonstrates the framework end to end. Its wide Slack manifest is a development reference, not a minimal production permission baseline.
| Demo | What it exercises |
|---|---|
/kickensink |
Interactive feature tour |
| Boltling | Shared JSON-backed pet, care actions, levels, and App Home dashboard |
| Terminal | Owner-restricted terminal streamed into Slack |
| AI conversation | Responses streaming, web search, scoped tools, and progress cards |
| Agent sessions | Durable work, titles/status, and Stop-driven cancellation |
| Work items | Entity previews, JSON storage, comments, and pagination |
Run Socket Mode alongside php bolt jobs:work for AI/agent work and php bolt demo:work for the pet, terminal, and work items. The example README documents required credentials and per-demo configuration.
Development
PHP and documentation checks
composer install
composer test
composer test:project
composer test:vcs-project
composer analyse
composer lint
composer format:check
npm run verify:docs
| Check | What it establishes |
|---|---|
| PHPUnit | Local runtime contracts without live Slack credentials |
| Fresh-project checks | Path/VCS production installs, discovery, recorded delivery, and durable recovery |
| Mago | Formatting, lint, and static-analysis gates |
| Documentation verifier | Local links, images, PHP syntax/imports, public factories and constructors |
| Opt-in live suite | Narrow real-workspace evidence; separate from ordinary tests |
Reference generators
npm ci npm run test:tooling npm run verify:generated npm run verify:transport npm run verify:parity
Regenerate pinned sources with npm run sync:references, then npm run generate. Generated output has ownership checks; edit the generators rather than generated files. Declaration-only scaffolds under src/Scaffold/ are excluded from discovery and are not implemented runtime features.
Development guide · Testing, security, and troubleshooting · Architecture.
Documentation
| Start here | For |
|---|---|
| Documentation index | All maintained adopter guides |
| Public interface map | Classes, source links and the relevant usage guides |
| Getting started | Project layout, source choices, and first listener |
| Slack setup with screenshots | Current app-settings UI and feature prerequisites |
| Building apps | Dispatch, acknowledgements, context, and errors |
| Durable operations | Recovery, cancellation, workers, and deployment |
| Upgrading | Package migration and operator actions |
The documentation lives in docs/ as Markdown, versioned with the code and readable directly on GitHub. Start at the documentation home. Dated docs/research/ notes record decisions and evidence; they are not current setup instructions. The parity audit distinguishes implemented capabilities, PHP adaptations, useful gaps, and Node-specific exclusions.
License
MIT. Upstream references and attribution are recorded in NOTICE.
