linecorp/line-bot-sdk
LINE Messaging API SDK for PHP helps you build LINE bots quickly. Install with composer require linecorp/line-bot-sdk, configure a Guzzle client and channel access token, then call APIs such as replyMessage. Requires PHP 8.2+.
1. Install (PHP 8.2+ required — Laravel 11/12 or Laravel 10 on PHP 8.2):
composer require linecorp/line-bot-sdk
The LINE\Laravel\LINEBotServiceProvider and LINEMessagingApi facade are auto-discovered; no manual registration needed. Set the three env vars from the LINE Developers console (Messaging API channel):
LINE_BOT_CHANNEL_ACCESS_TOKEN=<long-lived channel access token>
LINE_BOT_CHANNEL_ID=<channel id>
LINE_BOT_CHANNEL_SECRET=<channel secret>
Publish the config once so you can see what the facade binds:
php artisan vendor:publish --provider="LINE\Laravel\LINEBotServiceProvider" --tag=config
# -> config/line-bot.php
php artisan config:cache
2. First use case: an echo webhook. This is the canonical first task and exercises everything — signature verification, event parsing, reply.
// routes/api.php (must be the `api` group or CSRF-exempt)
Route::post('/line/webhook', [LineWebhookController::class, 'handle']);
// app/Http/Controllers/LineWebhookController.php
use LINE\Webhook\Parser\SignatureValidator; // used internally by the parser
use LINE\Webhook\Parser\EventRequestParser;
public function handle(Request $request)
{
$events = EventRequestParser::parseEventRequest(
$request->getContent(), // RAW body, not $request->all()
config('line-bot.channel_secret'),
$request->header('X-Line-Signature', ''),
);
foreach ($events as $event) {
if ($event instanceof \LINE\Webhook\Model\MessageEvent
&& $event->getMessage() instanceof \LINE\Webhook\Model\TextMessage) {
\LINEMessagingApi::replyMessage(new \LINE\Clients\MessagingApi\Model\ReplyMessageRequest([
'replyToken' => $event->getReplyToken(),
'messages' => [new \LINE\Clients\MessagingApi\Model\TextMessage(['text' => 'hello!'])],
]));
}
}
return response()->noContent(); // 200 fast
}
Where to look first, in this order:
examples/EchoBot/src/LINEBot/EchoBot/Route.php in the repo — the minimal end-to-end path.examples/KitchenSink/.../Route.php — the realistic router (follow/join/leave/postback/rich-menu handlers, message builders).config/line-bot.php after publishing — that is your actual runtime contract, not the README.https://line.github.io/line-bot-sdk-php/ — the generated models are OpenAPI-derived and the PHPDoc is the only usable reference for the ~200 model classes.Sending without a webhook (broadcast/push, backfill, ops scripts) needs only the token:
\LINEMessagingApi::pushMessage('U4af4980629...', [new TextMessage(['text' => 'shipped'])]);
Controller = verify + enqueue, job = work. LINE retries webhooks that don't get a 2xx and the user experience degrades if you do HTTP calls inline. Parse in the controller (cheap, and rejecting a bad signature early is important), then dispatch.
// controller
$events = EventRequestParser::parseEventRequest(
$request->getContent(), config('line-bot.channel_secret'), $request->header('X-Line-Signature', '')
);
foreach ($events as $event) {
HandleLineEvent::dispatch(
webhookEventId: $event->getWebhookEventId(),
type: $event->getType(),
replyToken: $event->getReplyToken() ?? null, // only on message events
userId: $event->getSource()?->getUserId(),
payload: $event->toArray(), // plain array survives the queue
);
}
return response()->noContent();
$event->toArray() is the pragmatic serialization boundary — the generated model objects serialize fine but are noisy and version-fragile in the queue payload. Rehydrate in the job or just read the array.
Reply fast, push later. replyToken is single-use and short-lived (~1 min). Any "typing… then a second bubble" or "call the LLM, then answer" flow must use pushMessage for the follow-up, not a second replyMessage on the same token. A common pattern:
// job
$api = app(MessagingApiApi::class);
try {
$api->replyMessage(new ReplyMessageRequest([
'replyToken' => $this->replyToken,
'messages' => [new TextMessage(['text' => '考え中…'])],
]));
} catch (ApiException $e) {
// 400 = token already used/expired -> fall back to push
if ($e->getCode() === 400) { /* push instead */ }
}
Reply with up to 5 message objects per request. Build them as model instances — a FlexMessage, QuickReply, TemplateMessage, ImageMessage, StickerMessage, LocationMessage. Flex is where most real UI lives:
$bubble = new FlexBubble(['body' => new FlexBox([
'layout' => 'vertical',
'contents' => [new FlexText(['text' => 'Order #1234', 'weight' => 'bold'])],
])]);
$api->replyMessage(new ReplyMessageRequest([
'replyToken' => $token,
'messages' => [new FlexMessage(['altText' => 'Order #1234', 'contents' => $bubble])],
]));
Correlate failures with x-line-request-id. When LINE support asks for it, you need it logged. Two mechanisms:
// success path: use the *WithHttpInfo variant
[$body, $status, $headers] = $api->replyMessageWithHttpInfo($request);
Log::info('line', ['request_id' => $headers['x-line-request-id'][0] ?? null, 'status' => $status]);
// failure path: ApiException carries headers + body
try {
$api->pushMessage($userId, $messages);
} catch (ApiException $e) {
Log::warning('line api error', [
'status' => $e->getCode(),
'request' => $e->getResponseHeaders()['x-line-request-id'][0] ?? null,
'body' => $e->getResponseBody(),
]);
throw $e; // let Horizon retry with backoff on 429/5xx
}
Bind the client in a service provider rather than relying on the facade. The facade resolves a single token from config; the moment you have two official accounts (staging bot + prod bot, or a brand matrix) you need explicit instances. Wrap it:
// app/Services/Line/LineClient.php
final class LineClient
{
public function __construct(
private MessagingApiApi $api,
private MessagingApiBlobApi $blob, // for GET /content downloads
) {}
/** @param array<\LINE\Clients\MessagingApi\Model\Message> $messages */
public function reply(string $token, array $messages): void
{
$this->api->replyMessage(new ReplyMessageRequest([
'replyToken' => $token, 'messages' => $messages,
]));
}
}
// AppServiceProvider::register()
$this->app->singleton(LineClient::class, function () {
$config = (new Configuration())->setAccessToken(config('services.line.token'));
$http = new \GuzzleHttp\Client(['timeout' => 10, 'connect_timeout' => 5]);
return new LineClient(
new MessagingApiApi(client: $http, config: $config),
new MessagingApiBlobApi(client: $http, config: $config),
);
});
For multi-tenant, key a factory on the channel id and cache instances in a small map — never let the facade be the implicit dependency.
Images and files require the Blob client. Events give you a messageId; the message body is not in the webhook.
$stream = $blob->getMessageContent($event->getMessage()->getId()); // PSR-7 StreamInterface
Storage::disk('s3')->writeStream("line/{$id}.jpg", $stream->detach());
Rich menus and audience APIs live on the same MessagingApiApi (createRichMenu, setRichMenuImage with an \SplFileObject, linkRichMenuIdToUser). For image uploads the generated signature takes a file resource — pass new \SplFileObject($path).
Webhook routing table. KitchenSink's pattern is worth copying: a switch on $event->getType() plus instanceof narrowing on both the event and its message, with an else that logs unknown types. LINE adds event types over time (this release line added PNP delivery webhooks); an unhandled type must not 500 or LINE will retry forever.
Testing. The API class takes any ClientInterface in the constructor, so swap a Guzzle MockHandler in the container and assert the outgoing request body:
$mock = new MockHandler([new Response(200, [], '{}')]);
$this->app->instance(MessagingApiApi::class, new MessagingApiApi(
client: new Client(['handler' => HandlerStack::create($mock)]),
config: (new Configuration())->setAccessToken('test'),
));
For webhook tests, sign the payload yourself so the parser path is exercised:
$body = json_encode(['destination' => 'x', 'events' => []]);
$sig = base64_encode(hash_hmac('sha256', $body, config('line-bot.channel_secret'), true));
$this->postJson('/api/line/webhook', [], ['X-Line-Signature' => $sig])->assertNoContent();
$request->getContent() or nothing. Signature validation is HMAC-SHA256 over the exact bytes LINE sent. $request->all() (re-encoded), TrimStrings, or any middleware that mangles the body will make every request fail validation with a signature error. Also add the webhook path to bootstrap/app.php's CSRF exception list / keep it in the api group.
Header name and case. X-Line-Signature is what the parser needs; behind some proxies/CDNs the header gets stripped. If validation always fails in staging but works on curl, check the proxy first, not your secret.
The facade is magic and only knows one channel. \LINEMessagingApi::replyMessage(...) returns a generated response model, not a plain array, and has no IDE completion. php artisan config:cache freezes the token — rotating the channel access token requires a re-deploy/re-cache. Do not read env() outside config/.
Reply token is single-use and short-lived. A duplicate reply attempt throws ApiException with HTTP 400. Webhook redelivery (LINE retries non-2xx) means your handler can run twice for the same webhookEventId — persist that id and return 200 immediately on duplicates, otherwise a retried postback creates two orders.
Max 5 messages per request, and model objects — not arrays. Passing nested arrays for messages, contents, or Flex nodes throws a type/validation error from the generated model setters. Every level must be an instantiated model class. The setter style ((new ReplyMessageRequest)->setReplyToken(...)) is verbose but surfaces missing required fields earlier and is the only style that gives you static analysis.
ApiException is per-client. \LINE\Clients\MessagingApi\ApiException will not catch errors from the blob client or the LIFF/insight clients. Catch the specific class, or catch \Throwable at the job boundary. $e->getResponseBody() is a JSON string, not an array — json_decode it.
429s are real. pushMessage, broadcast, and profile lookups have per-endpoint rate limits; the SDK does not retry. Let Horizon/queue retry with exponential backoff and set retryUntil, and consider caching getProfile() results — profiles change rarely and the call is rate-limited.
pushMessage returns per-recipient results. In multicast/broadcast responses, some users can be skipped (blocked, unfollowed). Inspect the response model's sentMessages/failure list; a 200 does not mean everyone got it.
Webhook types you must tolerate: unfollow (user blocked you — delete tokens, stop pushing), follow (new userId), postback (has data and optionally params), beacon, accountLink, memberJoined, plus PNP delivery webhooks. Guard every getMessage() with instanceof; group chats vs. 1:1 change what's available (source->getType() = user | group | room).
Partner-only features are gated. The new templated LINE notification messages (pushTemplatedMessagesByPhone, getPNPTemplatedMessageStatistics) and custom aggregation units require partner approval and hashed phone numbers; the SDK will happily build the request and the API will return 403. Don't plan a release around them without an application approved.
Versioning is OpenAPI-driven. Minor releases add schema and can add required constructor properties or new event classes; the project also ships security/business-driven changes as patch releases (per its stated versioning policy). Pin the version, upgrade deliberately, and re-run your webhook tests after upgrading — event model classes have moved between LINE\Webhook\Model and nested namespaces across majors.
PHP 8.2 floor. This is the constraint that will bite on older Laravel 9/10 apps (PHP 8.0/8.1). There is no maintained branch for older PHP — check before promising the integration to a legacy codebase.
Extension points worth knowing: every client accepts a custom Configuration (headers, host, setAccessToken, setUserAgent), so a proxy, an mTLS gateway, or a logging middleware is a GuzzleHttp\Client construction away. For per-request tracing, attach a Guzzle middleware that copies x-line-request-id from the response into your logger — far less invasive than swapping every call to a *WithHttpInfo variant.
How can I help you explore Laravel packages today?