Build your native helper with the PHP SDK
This guide is the end-to-end recipe for anyone integrating Pliic natively, without the embedded widget. It came out of the first real integration built this way (Pliic’s own team help panel) and collects the problems that showed up along the way. If you just need the quick reference for each SDK call, see PHP SDK; here the focus is the full flow and the decisions that keep you from shipping a security bug or leaking data between users.
What you’re building
Section titled “What you’re building”A help panel inside your own product: users open suggestions, vote, comment, and open support tickets without leaving your app and without seeing any Pliic branding. Behind the scenes, your backend talks to the Pliic API through the PHP SDK. From your user’s point of view, it’s just another screen in your system.
The golden rule: the secret key never reaches the browser
Section titled “The golden rule: the secret key never reaches the browser”The sk_live_... key grants full API access to your app. It can only ever live on your server. That means your frontend never talks directly to the Pliic API: it talks to routes on your own backend, and it’s your backend that calls the SDK.
In practice, that becomes a thin proxy. A group of routes authenticated by your own user session, with controllers that only translate the request into an SDK call:
// Every route below already went through YOUR app's own auth middleware// and its own throttle, separate from the Pliic API rate limit.Route::middleware(['auth', 'throttle:60,1']) ->prefix('help') ->group(function () { Route::get('suggestions', [HelpSuggestionsController::class, 'index']); Route::post('suggestions', [HelpSuggestionsController::class, 'store']); Route::post('suggestions/{id}/vote', [HelpSuggestionsController::class, 'vote']); Route::get('tickets/{id}', [HelpTicketsController::class, 'show']); Route::post('tickets/{id}/replies', [HelpTicketsController::class, 'reply']); });class HelpSuggestionsController{ public function __construct(private PliicClient $pliic) {}
public function index(Request $request): JsonResponse { $user = $request->user(); // the user authenticated in YOUR system
$result = $this->pliic->suggestions->list([ 'status' => $request->query('status'), 'search' => $request->query('search'), 'user_id' => (string) $user->id, 'user_email' => $user->email, ]);
return response()->json($result); }}The controller never receives sk_live_... from the client, never forwards the key anywhere visible, and never lets the user choose who they’re acting on behalf of. The identity always comes from your app’s authenticated session, never from a form field.
User identity
Section titled “User identity”Every write in the SDK (create, vote, addComment, reply) accepts a user with the identity of the person in your system:
$user = [ 'id' => (string) $request->user()->id, 'name' => $request->user()->name, 'email' => $request->user()->email,];
$this->pliic->suggestions->create([ 'user' => $user, 'title' => $request->input('title'), 'description' => $request->input('description'),]);Pliic creates or reuses the matching app user on its own. There are two pitfalls here that only show up once the helper is already live with real users.
Pliic’s identity is email-first. If two of your users, across different environments or by sheer coincidence, share the same id but have different emails, Pliic has no way to tell them apart from the id alone. Because of that, on every read that carries user context (suggestions->list, suggestions->get, tickets->list, tickets->get), send user_id and user_email together whenever your user has an email. Sending only user_id works, but it’s the more fragile path: any id collision turns into someone else’s data showing up for the wrong user.
Use a separate app per environment. A staging app pointed at a production key (or the other way around) is the most common way to pollute your real numbers with test data, and the reverse, production data showing up in a dev environment, is worse. Create a dedicated app (or at least a sandbox key) per environment and treat the key’s environment variable like any other per-environment secret.
Ticket ownership
Section titled “Ticket ownership”Fetching a ticket by id with no user context returns the ticket no matter who owns it. That’s intentional in the SDK, because the call exists so your own team can look up any ticket in the app; but inside the helper, where it’s the end user themselves looking at the screen, you need to scope the lookup to the owner:
public function show(Request $request, int $id): JsonResponse{ $user = $request->user();
try { $ticket = $this->pliic->tickets->get($id, [ 'user_id' => (string) $user->id, 'user_email' => $user->email, ]); } catch (NotFoundException) { abort(404); }
return response()->json($ticket);}Since SDK version 1.0.1, passing user_id/user_email to tickets->get() makes the API respond 404 when the ticket belongs to someone else, the exact same status as “doesn’t exist”. Handle both cases the same way in your catch: there’s no safe way to tell “doesn’t exist” apart from “isn’t yours” without giving your user a way to probe, by trial and error, which ticket ids exist in someone else’s account.
Whoever skips this parameter and trusts only the id from the URL hands over any ticket in the app to any user authenticated in your helper. It’s the easiest leak to introduce in this integration and the easiest to miss in manual testing, because your own test user rarely tries to access someone else’s ticket.
Handling errors gracefully
Section titled “Handling errors gracefully”Every SDK call that fails throws a typed exception, all extending Pliic\Exceptions\ApiErrorException. Map them to friendly responses in your proxy instead of letting the exception surface as a generic error:
use Pliic\Exceptions\NotFoundException;use Pliic\Exceptions\ValidationException;use Pliic\Exceptions\RateLimitException;use Pliic\Exceptions\ApiErrorException;
try { $result = $this->pliic->suggestions->create($payload);} catch (ValidationException $e) { // 422: validation error, the field errors are in $e->errors() return response()->json(['errors' => $e->errors()], 422);} catch (NotFoundException $e) { // 404: suggestion/ticket doesn't exist (or isn't the user's, for tickets->get) abort(404);} catch (RateLimitException $e) { // 429: the plan's calls-per-hour limit was hit return response()->json(['message' => 'Too many requests, try again shortly.'], 429);} catch (ApiErrorException $e) { // catches the rest: 401/403 from key configuration, and any 5xx // (including a 503 during Pliic maintenance) lands here, because // the SDK has no dedicated exception for server-side statuses report($e);
return response()->json(['message' => 'Could not complete this action right now.'], 502);}A second layer of polish: hide the helper’s own UI when the key isn’t configured, instead of letting the user land on a broken screen. A simple flag based on the environment variable’s presence does the job:
// Somewhere shared (a Gate, a props provider, whatever fits your app)$helperEnabled = filled(config('services.pliic.secret'));Shapes and enums
Section titled “Shapes and enums”These are the fields and enums that trip up anyone reading the API response for the first time. All of them come straight from Pliic’s OpenAPI specification (/api/v1/openapi.json).
| Field / enum | Where it shows up | Detail |
|---|---|---|
Suggestion status | Suggestion.status | pending, under_review, planned, in_progress, done, declined |
Ticket status | Ticket.status | open, pending, resolved, closed |
Ticket type | Ticket.type | bug, feature_request, question, billing, other |
Ticket priority | Ticket.priority | low, normal, high, urgent |
description vs body | Creating a suggestion vs reading one back | You send description in suggestions->create(), but the response (and any later read) returns the same text in the body field. Different names for the same information, depending on whether you’re writing or reading. |
author_type | SuggestionComment.author_type | app_user or member. This is the enum for who wrote a suggestion comment. |
sender_type | TicketMessage.sender_type | user or agent. The equivalent enum for who wrote a ticket message, with a different name and different values than author_type. Don’t mix the two when rendering an item’s author. |
vote_count vs votes_count | Suggestion.vote_count vs the response of suggestions->vote() | A suggestion’s total vote count comes as vote_count when you list or fetch the suggestion, but the immediate response from vote() returns the same number as votes_count. If your frontend updates the counter optimistically, double-check which of the two fields you’re reading. |
Ticket author | Ticket.author | Since v1.0.1, every ticket carries { external_id, name } for its author. Use it to show “opened by so-and-so” in your UI, but don’t use it as a substitute for the user_id/user_email parameter of tickets->get(): author is display-only, the parameter is what enforces ownership. |
Suggestion author | Suggestion.author | Same { external_id, name } shape as Ticket.author, also display-only (“suggested by so-and-so”). It doesn’t filter or restrict anything by itself: that’s still the job of the user_id/user_email you already send to suggestions->list()/suggestions->get(). |
Testing
Section titled “Testing”Until an official fake ships, test your helper by injecting a fake HttpClientInterface into PliicClient:
use Pliic\HttpClient\ApiResponse;use Pliic\HttpClient\HttpClientInterface;
class FakePliicHttpClient implements HttpClientInterface{ public function __construct(private array $responses) {}
public function request(string $method, string $url, array $headers, ?string $body = null): ApiResponse { // returns the canned response for whatever path this test calls, // without hitting the real network [$status, $payload] = $this->responses[$method.' '.$url] ?? [200, []];
return new ApiResponse($status, json_encode($payload)); }}
$pliic = new PliicClient('sk_live_test', 'https://pliic.com', new FakePliicHttpClient([ 'GET https://pliic.com/api/v1/tickets/7' => [404, ['message' => 'Not found']],]));An official fake (Pliic\Testing) is on the way to remove this hand-rolled part (track it in issue #191); once it ships, this guide will get its own section with the ready-made package.
Building with AI? Copy this prompt
Section titled “Building with AI? Copy this prompt”The prompt below encodes the entire recipe from this guide. Paste it into Claude Code, Cursor, or your assistant of choice, adjust the bracketed parts, and you should walk away with a helper that’s already free of this integration’s most common mistakes.
I want to build a native help panel in my [APP/FRAMEWORK NAME], usingPliic's official PHP SDK (pliic/pliic-php) to expose suggestions andsupport tickets inside my own interface, without Pliic's embedded widget.
Mandatory rules for this integration:
1. Pliic's secret key (sk_live_...) stays only on my backend, never on the frontend. All access goes through proxy routes in my own app (e.g. /help/*), authenticated by my user's session, with thin controllers that only translate the request into an SDK call.
2. On every write (suggestions->create, suggestions->vote, suggestions->addComment, tickets->create, tickets->reply), build the `user` object from the user authenticated in MY session (id, name, email), never from a field sent by the client.
3. Pliic's identity is email-first. On every READ that carries user context (suggestions->list, suggestions->get, tickets->list, tickets->get), send user_id AND user_email together whenever the user has an email. Never rely on user_id alone: id collisions across environments are a real problem.
4. Use a separate Pliic app (or at least a separate key) per environment: dev/staging must never write into the production app.
5. When fetching a specific ticket (tickets->get), always pass the authenticated user's user_id/user_email. Since SDK v1.0.1, this makes the API respond 404 when the ticket belongs to someone else. Treat that 404 exactly like "doesn't exist" in my proxy: never let the user tell "doesn't exist" apart from "isn't theirs".
6. Map the SDK's exceptions (all extending Pliic\Exceptions\ApiErrorException) to friendly responses: ValidationException (422, use $e->errors()), NotFoundException (404), RateLimitException (429), and a generic ApiErrorException catch for the rest (401/403 from configuration, and any 5xx, since the SDK has no dedicated exception for server-side statuses).
7. Hide the helper's UI (or disable the route) when Pliic's key isn't configured in the environment, instead of letting the user land on a broken screen.
8. Use the API's correct field names (derive them from the OpenAPI spec at /api/v1/openapi.json, don't guess): - Suggestion.status: pending, under_review, planned, in_progress, done, declined - Ticket.status: open, pending, resolved, closed - Ticket.type: bug, feature_request, question, billing, other - Ticket.priority: low, normal, high, urgent - When CREATING a suggestion the field is `description`, but when READING the suggestion back the same text comes in the `body` field. - SuggestionComment uses `author_type` (app_user/member); TicketMessage uses `sender_type` (user/agent) — these are not the same enum, don't mix them up. - Suggestion.vote_count is the running total; the response of suggestions->vote() returns the same number as votes_count (different name). - Ticket.author ({ external_id, name }) is display-only, it does not replace the ownership parameter in tickets->get().
Build the routes, controllers, error handling, and tests (with a fakeHttpClientInterface) following these rules. Flag anything that conflictswith my project's structure before generating code.An official Pliic MCP is on the roadmap as the next step in this experience: instead of pasting this prompt, the idea is for the AI assistant itself to query Pliic directly through MCP tools. Until then, this prompt and the guide above are the shortest path to a correct native helper.