Laravel
Four Integrations, Four Ideas of What a "Credential" Is
Wiring Slack, Telegram, Trello and Linear into Laravel, and the exact clicks to get the keys. Four external systems, four completely different credential models, one stateless package shape.
I recently had a task to make in one Laravel application the integration with four external well-known systems: post an alert into a Slack channel, ping a person on Telegram, drop a card onto a Trello list, open an issue in Linear. On paper these are the same task: "send a thing over HTTP with some credential attached."
In practice, every one of those systems has a completely different idea of what a credential even is.
Slack hands you a URL that is itself the secret. Telegram gives you a static bot token from a chat bot named BotFather, and expects you to invent the second secret. Trello has an API key that identifies your app and a member token that arrives in a URL fragment your server never sees. Linear is the only grown-up in the room: proper OAuth 2, access tokens that die in 24 hours, refresh tokens that rotate on every use.
I ended up extracting each client into its own small package, all built on the same principle: the package is stateless. It holds no tokens, remembers no chats, persists nothing. Your application owns storage (encrypted columns, a vault, whatever you trust); the package owns the wire protocol. That one decision made all four packages boring to test and safe to reuse, and it forced me to actually understand each system's credential model instead of letting a library hide it.
Here's the map, from simplest to most involved. For each: what the package does, and the exact steps to get the credentials from the other side.
All four are on Packagist under MIT: labrodev/laravel-slack, labrodev/laravel-telegram, labrodev/laravel-trello, labrodev/laravel-linear.
Slack: the URL is the secret
Package: labrodev/laravel-slack
Slack's incoming webhooks are the most honest credential model of the four: there is no OAuth flow, no bot token, no app-level secret to configure. Slack gives you a URL, and anyone holding that URL can post into the channel it points at. The URL is the bearer secret.
The package is a thin client around exactly that: SlackClient::send() posts Block Kit blocks plus a fallback text to whatever webhook URL you pass in. Two things I cared about beyond the happy path:
SlackWebhookUrl::validate()checks a pasted URL (scheme, host, path shape) before you store it, so a user can't savehttps://evil.example/stealas their "Slack webhook" and have your server dutifully POST alert payloads to it.- A dedicated
SlackWebhookRevokedexception when Slack answers 404/410: that means someone deleted the webhook on Slack's side, and you should mark the stored credential dead instead of retrying it forever.
And because the URL is the secret, no exception in the package ever interpolates it into a message. A webhook URL in a log file is a leaked credential.
Getting the webhook URL
- Go to api.slack.com/apps → Create New App → From scratch. Name it, pick the workspace.
- In the app's sidebar, open Incoming Webhooks and flip the toggle to On.
- Click Add New Webhook to Workspace, choose the target channel, hit Allow.
- Copy the generated URL: it looks like
https://hooks.slack.com/services/T.../B.../....
That's it. One URL per channel; want to post to three channels, repeat step 3 three times. Store it encrypted, treat it like a password.
$slackClient->send(
webhookUrl: $webhookUrl,
blocks: $blocks,
fallbackText: 'A new incident was reported.',
);
Telegram: one token from a bot, one secret from you
Package: labrodev/laravel-telegram
Telegram's model is a static bot token, but with a twist that trips people up: a bot cannot message a user first. The user has to open a chat with your bot and press Start; only then do you learn their chat_id, and only then can you push messages. So a Telegram integration is always two-directional: sending messages out, and running a webhook to catch that first /start.
The package covers both sides. TelegramClient sends messages and registers the webhook; TelegramWebhookSecretVerifier and TelegramUpdateParser handle the incoming leg: verify the secret header, parse the update into a typed command object, route on it.
The second secret is the part Telegram leaves to you. When you register a webhook, you hand Telegram a secret_token of your own invention, and Telegram echoes it back in the X-Telegram-Bot-Api-Secret-Token header on every update. Without it, anyone who guesses your webhook URL can feed you fake updates.
One design decision worth stealing: when the webhook secret isn't configured at all, the verifier throws instead of returning false. A missing secret is your deployment mistake and should explode as a 500, not be quietly swallowed as an ordinary rejected request.
Getting the credentials
- In Telegram, open a chat with @BotFather and send
/newbot. Give it a display name and a username ending inbot. - BotFather replies with the bot token:
123456:ABC-DEF.... That'sTELEGRAM_BOT_TOKEN. - Generate your own webhook secret: any high-entropy string (
openssl rand -hex 32is fine). That'sTELEGRAM_WEBHOOK_SECRET. - Register the webhook once, from a console command, passing your public HTTPS URL and that same secret:
$telegramClient->setWebhook(
url: 'https://my-app.test/telegram/webhook',
secretToken: config('telegram.webhook_secret'),
allowedUpdates: ['message'],
);
- The
chat_idyou'll send messages to arrives in the first update after the user presses Start: capture it in your webhook handler and persist it.
Gotcha: the bot token rides in the request URL path itself (https://api.telegram.org/bot{token}/...), so the same rule as Slack applies: nothing about a failed request may ever end up in an exception message.
Trello: the token arrives in a place your server can't see
Package: labrodev/laravel-trello
Trello predates the OAuth 2 world and it shows, charmingly. You need two credentials: an API key that identifies your application (these days it lives inside a Power-Up, Trello's app container), and a member token that a user grants you through Trello's authorize page.
The strange part: after the user clicks Allow, Trello redirects back to your return URL with the token in the URL fragment: https://my-app.test/trello/callback#token=.... Fragments never reach the server. Your backend gets a request with no token in it, and a small piece of frontend JavaScript has to read window.location.hash and POST the token back to you. It feels like a hack the first time; it's simply how Trello's response_type=token flow works.
The package builds the authorize URL (with a state value so you can verify the round-trip), and gives you a TrelloClient where every call takes the member token explicitly: fetch the member, list their open boards, list a board's lists, create a card, archive a card, revoke the token on disconnect. Validating a freshly captured token is just calling member() and catching TrelloAuthFailed.
Getting the credentials
- Go to the Power-Up admin portal and create a new Power-Up. Name and workspace are what matter; the iframe connector URL can point at your app.
- On the Power-Up's API key tab, generate the key. That's
TRELLO_API_KEY. - In the same place, add your application's origin to Allowed origins, otherwise the authorize redirect back to your app is refused.
- Set
TRELLO_RETURN_URLto your callback route, then send the user off:
$url = new TrelloAuthorizeUrl()->build(state: $state);
return redirect()->away($url);
- On the callback page, read the token out of
location.hashwith a few lines of JS, POST it to your backend, validate it withmember(), then store it encrypted.
Ask for expiration=never (the package's authorize URL does) and the token lives until the user revokes it: no refresh choreography at all. Trello's laziest-possible token lifecycle is genuinely pleasant once you're past the fragment dance.
Linear: real OAuth 2, with rotating refresh tokens
Package: labrodev/laravel-linear
Linear is the only one of the four with a textbook OAuth 2 authorization-code flow, and the only one where the credential lifecycle will actively punish sloppy storage. Access tokens expire after roughly 24 hours. Refresh tokens are single-use and rotate on every refresh: each refresh() call returns a new pair, and the old refresh token is dead the moment the new one is issued. If you persist only the access token and keep the old refresh token around, the second refresh will fail and your user has to re-authorize. Always store the full returned pair, atomically.
The package splits the two concerns. LinearOAuthClient implements the flow: build the authorize URL (scope read,write, actor=user, prompt=consent), exchange the callback code for a token set, refresh, revoke on disconnect. LinearClient talks GraphQL with whatever access token you hand it: list teams, create an issue against a team, close an issue by moving it to the team's completed workflow state.
The exception design carries its weight here too: LinearInvalidGrant specifically means "this refresh token is dead, send the user back through OAuth", distinct from LinearAuthFailed (bad access token on an API call) and LinearRequestFailed (anything else). Distinguishing those three is the difference between a self-healing integration and one that silently stops creating issues.
Getting the credentials
- In Linear, open Settings → API → OAuth applications and create a new application.
- Set the callback URL to the route in your app that will receive the authorization code: it must match
LINEAR_REDIRECT_URIexactly. - Copy the Client ID and Client Secret into
.env. - Redirect the user, exchange the code, and persist everything the exchange returns:
$linearTokenSet = app(LinearOAuthClient::class)->exchangeCode(
code: $request->string('code')->toString(),
);
$linearTokenSet->accessToken; // valid ~24h
$linearTokenSet->refreshToken; // single use, rotates on refresh
$linearTokenSet->expiresAt; // when to refresh
- Before each API call (or on a schedule), refresh when
expiresAtis near, and overwrite both tokens with the rotated pair. OnLinearInvalidGrant, clear the stored set and ask the user to reconnect.
What the four taught me
Lined up next to each other, the integrations form a neat spectrum of credential complexity:
| System | App credential | User credential | Expiry |
|---|---|---|---|
| Slack | - | webhook URL | until revoked |
| Telegram | bot token + your own webhook secret | chat id (not secret) | never |
| Trello | API key (Power-Up) | member token, via URL fragment | never (if you ask) |
| Linear | OAuth client id + secret | access + rotating refresh token | ~24 hours |
Three rules survived contact with all four systems, and they're the ones I'd carry into any future integration:
Keep the client stateless. The package takes credentials as arguments and returns data; the application decides where secrets live. Every time I've seen an integration library that "helpfully" persists tokens for you, I've also seen the migration where someone had to dig them back out of it.
Assume the credential will end up in a log. Slack's secret is a URL. Telegram's rides in the URL path. Trello's travels in query strings. One careless $e->getMessage() with an interpolated request URL and you've logged a live credential. All four packages throw exceptions with fixed messages only, nothing from the request or the response body, ever.
Revocation is a state, not an error. A deleted Slack webhook, a dead Linear refresh token: these aren't retryable failures, they're facts about your stored credential. Model them as their own exception types, catch them, and mark the credential dead. The alternative is a queue quietly retrying a 410 for a week.
Four systems, four credential models, one shape of package. The docs portals were four very different afternoons; the Laravel side, in the end, looks almost identical.