PHP SDK
pliic/pliic-php is the official PHP SDK. It is for integrating Pliic natively in your backend: instead of embedding the widget, your system creates suggestions and tickets on behalf of your users, renders the board with vote state, replies to tickets, and consumes webhooks, all through the REST API.
If the embedded widget already covers your needs, you don’t need the SDK. Reach for it when you want full control of the experience inside your product.
This page is the quick reference for each call. For the full recipe on building a native help panel with the SDK, including key proxying, user identity, and ticket ownership, see Build your native helper with the PHP SDK.
Install
Section titled “Install”composer require pliic/pliic-phpRequires PHP 8.2+ with ext-curl and ext-json. No other dependencies.
Authentication
Section titled “Authentication”Create the client with your app’s secret key (sk_live_..., on the app’s Install tab, where the PHP quickstart lives too):
use Pliic\PliicClient;
$pliic = new PliicClient('sk_live_...');Endpoints require scopes on the key (suggestions:read, suggestions:write, tickets:read, tickets:write, and so on) and the API feature available on your plan.
Acting on behalf of your user
Section titled “Acting on behalf of your user”Every write accepts a user object with the identity of the user in your system. Pliic creates or reuses the matching user automatically (same email means same person):
$pliic->suggestions->create([ 'user' => $user, 'title' => 'Dark mode', 'description' => 'Easier on the eyes at night.',]);You never need Pliic’s internal id: the id is the one from your own database.
Suggestions
Section titled “Suggestions”// Board with the current user's vote state$pliic->suggestions->list(['status' => 'planned', 'search' => 'dark', 'user_id' => 'u_123']);
$pliic->suggestions->get(42, ['user_id' => 'u_123']); // includes user_has_voted$pliic->suggestions->vote(42, ['user' => $user]); // votes; calling again undoes it$pliic->suggestions->comments(42);$pliic->suggestions->addComment(42, ['user' => $user, 'body' => 'Great idea!']);Tickets
Section titled “Tickets”$pliic->tickets->list(['user_id' => 'u_123']); // that user's tickets$pliic->tickets->create(['user' => $user, 'subject' => 'Checkout error', 'body' => '...', 'type' => 'bug']);$pliic->tickets->get(7, ['user_id' => 'u_123']); // 404 if the ticket isn't u_123's$pliic->tickets->reply(7, ['user' => $user, 'body' => 'More detail here...']);Passing user_id/user_email to tickets->get() scopes the lookup to that author: if the ticket exists but belongs to someone else, the response is 404 (since SDK version 1.0.1), the same status as “doesn’t exist”. Without this parameter, the call returns any ticket in the app by id, so use it whenever it’s the end user themselves looking up the ticket.
Only the ticket author can reply through it, and your team’s internal notes never appear in the returned thread.
Widget token (SSO)
Section titled “Widget token (SSO)”If you also embed the widget, mint the userToken server-side with the SDK:
use Pliic\UserToken;
$token = UserToken::mint($secretKey, [ 'id' => 'u_123', 'name' => 'Ana',]);Hand $token to your frontend as the widget’s userToken.
Webhooks
Section titled “Webhooks”Verify the signature before trusting any payload:
use Pliic\Webhook;use Pliic\Exceptions\SignatureVerificationException;
try { $event = Webhook::constructEvent( $request->getContent(), $request->header('X-Pliic-Signature'), $endpointSecret, // whsec_... );} catch (SignatureVerificationException $e) { abort(400);}
match ($event->type) { 'suggestion.created' => handleSuggestion($event->data), 'ticket.created' => handleTicket($event->data), default => null,};Signature format details and available events: Webhooks.
Errors
Section titled “Errors”API failures become typed exceptions, all extending Pliic\Exceptions\ApiErrorException:
| Status | Exception |
|---|---|
| 401 | AuthenticationException |
403 + error: insufficient_scope | InsufficientScopeException (extends PermissionException) |
| 403 | PermissionException (plan feature not available, and any other refusal) |
| 404 | NotFoundException |
| 422 | ValidationException ($e->errors() has the per-field errors) |
| 429 | RateLimitException |
Mapping is driven by the response’s stable error code, never by the message text, so rewording an error never breaks your catch blocks. Network-level failures become Pliic\Exceptions\TransportException.
Missing scope
Section titled “Missing scope”Every new key starts read-only: it carries suggestions:read and tickets:read and nothing else, so your first create() fails with a 403 until someone enables the write scope. It is not a problem with your payload.
use Pliic\Exceptions\InsufficientScopeException;
try { $pliic->tickets->create(['user' => $user, 'subject' => 'Cannot log in']);} catch (InsufficientScopeException $e) { $e->requiredScope(); // 'tickets:write' $e->grantedScopes(); // ['suggestions:read', 'tickets:read'] $e->manageScopesUrl(); // direct link to Settings → API Keys → Scopes $e->docsUrl();}Because InsufficientScopeException extends PermissionException, code that already catches PermissionException keeps working. Narrow to the specific exception only where you want to tell “the key lacks permission” apart from “the plan does not include this feature”.
To fix it: Settings → API Keys → Scopes, on the app the key belongs to. Details in API keys.
Testing your integration
Section titled “Testing your integration”Since version 1.1.0, the SDK ships with a fake HTTP client ready for your tests: Pliic\Testing\FakeHttpClient. Don’t write your own fake to simulate the API — response shapes drift subtly from what the real API returns (including newer fields, like author), and that’s exactly the kind of test-passes-but-production-breaks bug FakeHttpClient exists to eliminate: the payloads it uses are checked against the real OpenAPI spec in CI, so they don’t go stale.
Inject the fake in place of the real HTTP transport:
use Pliic\PliicClient;use Pliic\Testing\FakeHttpClient;
$fake = new FakeHttpClient();$pliic = new PliicClient('sk_test_fake', 'https://pliic.com', $fake);
$pliic->suggestions->list(); // realistic list, zero configurationWhen a test needs specific data, seed the payload:
$fake->seedSuggestion(['id' => 42, 'title' => 'Dark mode', 'vote_count' => 12]);$pliic->suggestions->get(42); // returns the seeded suggestion
$fake->seedError(422, 'Invalid', ['title' => ['Title already exists.']]);$pliic->suggestions->create(['user' => $user, 'title' => 'Duplicate']); // throws ValidationException
$fake->seedInsufficientScope('tickets:write');$pliic->tickets->create(['user' => $user, 'subject' => 'Hi']); // throws InsufficientScopeExceptionownedByEmail()/ownedByUserId() mirror the real API’s ownership rule (looking up a ticket with someone else’s user_email/user_id returns 404). Once either is set, the fake denies every request that doesn’t carry that exact parameter — including a call that, against the real API, wouldn’t have been restricted (the real API only checks ownership when user_id/user_email is sent). Use a fresh new FakeHttpClient() between scenarios that need both behaviors in the same test.
failNextWithTransportError() simulates a network failure on the next call only. And you can check what was actually sent:
$fake->assertRequested('POST', '/suggestions/42/vote');$fake->assertRequestCount(2);
expect($fake->lastRequestBody())->toBe(['user' => $user, 'title' => 'Dark mode']);$fake->requests holds every call made (method, url, headers, body). If you need a payload outside the fake — for example, to compare against in a controller test — Pliic\Testing\Fixtures exposes each one directly (Fixtures::suggestion(), Fixtures::ticket(), …).
Laravel integration
Section titled “Laravel integration”Since version 1.2.0, the SDK ships an optional Laravel bridge, auto-discovered by composer: it only loads when your host application is a Laravel project, and it never adds illuminate/support as a required dependency of the SDK, so plain-PHP consumers are unaffected. Requires Illuminate ^11 or newer.
1. Install (same command as always):
composer require pliic/pliic-php2. Publish the config and set your environment variables:
php artisan vendor:publish --tag=pliic-configPLIIC_API_KEY=sk_live_...PLIIC_BASE_URL=https://pliic.comPLIIC_WEBHOOK_SECRET=whsec_...Pliic\PliicClient is now bound as a singleton in the container, resolvable anywhere in your app:
use Pliic\PliicClient;
$pliic = app(PliicClient::class);3. Register the webhook route and listen for the event:
use Pliic\Laravel\Pliic;
Pliic::webhooks('/webhooks/pliic'); // POST /webhooks/pliicThe route already verifies X-Pliic-Signature for you and dispatches Pliic\Laravel\Events\WebhookReceived — your app never touches the raw payload or the signature check. It’s also already exempt from CSRF, so it can live safely in routes/web.php with no extra setup.
use Illuminate\Support\Facades\Event;use Pliic\Laravel\Events\WebhookReceived;
Event::listen(WebhookReceived::class, function (WebhookReceived $received): void { match ($received->event->type) { 'suggestion.created' => notifyTeamOfNewSuggestion($received->event->data), default => null, };});author vs sender: avoiding self-notifications
Section titled “author vs sender: avoiding self-notifications”Every webhook payload carries an author (the owner of the record) and a sender (whoever triggered this specific event) — they may or may not be the same person. Compare them before notifying, so you don’t ping someone about their own action:
use Illuminate\Support\Facades\Event;use Pliic\Laravel\Events\WebhookReceived;
Event::listen(WebhookReceived::class, function (WebhookReceived $received): void { if ($received->event->type !== 'suggestion.commented') { return; }
$author = $received->event->data['author']; // ['external_id' => ..., 'name' => ...] $sender = $received->event->data['sender']; // ['type' => 'app_user'|'member', 'external_id' => ..., 'name' => ...]
if ($sender['external_id'] === $author['external_id']) { return; // the author commented on their own suggestion — nothing to notify }
notify($author, "{$sender['name']} commented on your suggestion.");});sender['type'] is 'member' when someone on your team acted from the Pliic dashboard (external_id is always null — members aren’t app users) and 'app_user' when it was the end user themselves. More on both fields: Identifying who did what.
Building the UI in JavaScript?
Section titled “Building the UI in JavaScript?”To build the interface in the browser (instead of the backend), use @pliic/sdk, which talks to the widget API using the public key.
Next steps
Section titled “Next steps”- Build your native helper with the PHP SDK: the full recipe, with key proxying, user identity, ticket ownership, and a ready-made AI prompt to generate the helper.