frlnc/php-slack
A lightweight PHP client for the Slack API, giving PHP and Laravel applications a simple way to call Slack Web API methods and integrate notifications, channels, and workspace data without heavy dependencies.
Install the package and keep the token out of code:
composer require frlnc/php-slack
// config/services.php
'slack' => [
'token' => env('SLACK_TOKEN'), // xoxb-... bot token
],
Register the client once in a service provider. Commander is stateless per token, so a singleton is appropriate for single-workspace apps:
// app/Providers/AppServiceProvider.php
use Frlnc\Slack\Core\Commander;
use Frlnc\Slack\Http\CurlInteractor;
public function register(): void
{
$this->app->singleton(Commander::class, function () {
$interactor = new CurlInteractor();
$interactor->setTimeout(10);
return new Commander(config('services.slack.token'), $interactor);
});
}
Your first useful call is almost always posting a message:
$response = app(Commander::class)->post('chat.postMessage', [
'channel' => 'C0123456789',
'text' => 'Deploy finished on ' . config('app.env'),
]);
$body = $response->getBody(); // decoded array
if (! ($body['ok'] ?? false)) {
throw new RuntimeException('Slack: ' . ($body['error'] ?? 'unknown'));
}
Key files to read before doing anything non-trivial:
src/Frlnc/Slack/Core/Commander.php — how endpoint names, the token, and HTTP verbs are assembled.src/Frlnc/Slack/Http/CurlInteractor.php — transport, timeouts, encoding of request parameters, SSL options.src/Frlnc/Slack/Http/SlackResponse.php — getBody(), getStatusCode(), getRawBody(), headers.src/Frlnc/Slack/Contracts/Http/Interactor.php — the seam you will likely implement for retries/logging.README.md — endpoint naming convention (chat.postMessage, not a URL).The package is a thin HTTP wrapper over https://slack.com/api/{method}. It does not validate responses, paginate, retry, or format messages. Plan to build those yourself.
Wrap the Commander in your own service. Do not scatter $commander->post(...) through controllers. Centralize the ok check and typed helpers:
namespace App\Services;
use Frlnc\Slack\Core\Commander;
use RuntimeException;
class SlackClient
{
public function __construct(private Commander $commander) {}
public function postMessage(string $channel, string $text, array $extra = []): array
{
$response = $this->commander->post('chat.postMessage', array_merge([
'channel' => $channel,
'text' => $text,
], $extra));
return $this->assertOk($response->getBody(), 'chat.postMessage');
}
public function call(string $method, array $params = [], string $verb = 'post'): array
{
$response = $this->commander->{$verb}($method, $params);
return $this->assertOk($response->getBody(), $method);
}
private function assertOk(?array $body, string $method): array
{
if (! is_array($body) || ! ($body['ok'] ?? false)) {
throw new RuntimeException(
"Slack {$method} failed: " . ($body['error'] ?? 'invalid response')
);
}
return $body;
}
}
Use a Laravel notification channel. Store slack_id on users, implement routeNotificationForSlack(), and let the channel call your SlackClient. This is the cleanest integration for user-directed messages and keeps Slack payload formatting near the notification class.
public function routeNotificationForSlack(): ?string
{
return $this->slack_id; // C… or U… ID, not a display name
}
Queue writes and handle retryable errors explicitly. Slack write endpoints are network-bound and rate limited; jobs give you backoff. Only retry transient failures:
public function handle(SlackClient $slack): void
{
try {
$slack->postMessage($this->channel, $this->text);
} catch (RuntimeException $e) {
$retryable = str_contains($e->getMessage(), [
'ratelimited', 'internal_error', 'service_unavailable', 'fatal_error',
]);
if ($retryable) {
$this->release(30); // or throw for Laravel's backoff
return;
}
throw $e; // channel_not_found, invalid_auth, missing_scope are permanent
}
}
Paginate manually with cursor. There is no pagination helper:
$cursor = '';
do {
$body = $slack->call('users.list', array_filter([
'limit' => 200,
'cursor' => $cursor !== '' ? $cursor : null,
]), 'get');
foreach ($body['members'] as $member) {
// ...
}
$cursor = $body['response_metadata']['next_cursor'] ?? '';
} while ($cursor !== '');
Extend the interactor for logging, headers, or retries. This is the package's main extension point. Prefer composition (implement Interactor with Guzzle) when you need real retry logic or response headers:
class LoggingInteractor extends CurlInteractor
{
public function execute($method, $url, array $parameters = [], array $headers = [])
{
$start = microtime(true);
$response = parent::execute($method, $url, $parameters, $headers);
Log::info('slack.request', [
'method' => $method,
'url' => $url,
'status' => $response->getStatusCode(),
'duration_ms'=> (int) ((microtime(true) - $start) * 1000),
// never log $parameters: it contains the token
]);
return $response;
}
}
Fake the interactor in tests. Http::fake() will not intercept this package because it uses cURL directly. Bind a fake Interactor that returns canned SlackResponse objects, or mock Commander with Mockery. Assert on the recorded parameters to prove the payload shape.
Build a factory for multi-workspace apps. A singleton Commander carries one token. For multiple teams, resolve a Commander per token via a factory keyed by workspace/team ID; do not share one instance.
Format rich messages carefully. Because parameters are form-encoded, blocks, attachments, and similar structured fields must be JSON strings:
$slack->postMessage($channelId, 'Deployment complete', [
'blocks' => json_encode([/* Block Kit array */]),
]);
Store returned ts values. chat.postMessage returns ts and channel. Persist them if you plan to thread replies (thread_ts) or edit messages later (chat.update requires both channel and ts).
{"ok": false, "error": "..."} with a 200 status. The package never throws. Every call path must check ok, including reads.CurlInteractor in the installed version before production; check timeouts, SSL verification, and parameter encoding. If SSL peer verification is disabled there, subclass and enable it.CurlInteractor uses query-string style encoding, so ['blocks' => [...]] becomes blocks[0][type]=... rather than the JSON Slack expects. Always json_encode() structured payloads.get() calls it can appear in the URL/query string and leak into proxy or access logs. Prefer post() for everything, or use a custom interactor that sends Authorization: Bearer ….Retry-After, or {"ok":false,"error":"ratelimited"} on older paths. Detect both, release the job with that delay, and add jitter. The package does not inspect headers or back off for you.channels.list, users.list, conversations.history, and friends truncate. Loop response_metadata.next_cursor; an empty string means done. Expect legacy paging objects on some endpoints too.files.upload is awkward. Form-encoding plus local files requires CURLFile, which this interactor generally does not handle cleanly. For text files, pass content directly (under Slack's 1 MB limit); for larger/binary files, use a custom Guzzle-backed interactor or a separate upload client.'chat.postMessage', not a full URL or a leading slash. Commander prepends the API base URL, so incoming webhooks and arbitrary URLs are not supported.#general or @alice sometimes work but are brittle across renames and private channels. Resolve once with conversations.list/users.list and cache the IDs.invalid_auth/not_authed means a bad or revoked token; missing_scope includes needed and provided fields — log them and fix app scopes; not_in_channel means invite the bot or grant chat:write.public; channel_not_found usually means you passed a name instead of an ID.username and icon_emoji. as_user and identity overrides are legacy behavior. Set the bot's display identity in the Slack app configuration instead.chat.postMessage. Only retry on 429/5xx/network failures, and where duplicates matter, record a dedupe key or the returned ts before retrying.conversations.* over channels.* for new work, expect newer fields on files.* and users.*, and test payloads against the live API rather than assuming the README's examples still reflect Slack's current responses.token.composer why-not frlnc/php-slack to see the conflict.How can I help you explore Laravel packages today?