Skip to content

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.

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.

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.

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.

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'));

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 / enumWhere it shows upDetail
Suggestion statusSuggestion.statuspending, under_review, planned, in_progress, done, declined
Ticket statusTicket.statusopen, pending, resolved, closed
Ticket typeTicket.typebug, feature_request, question, billing, other
Ticket priorityTicket.prioritylow, normal, high, urgent
description vs bodyCreating a suggestion vs reading one backYou 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_typeSuggestionComment.author_typeapp_user or member. This is the enum for who wrote a suggestion comment.
sender_typeTicketMessage.sender_typeuser 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_countSuggestion.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 authorTicket.authorSince 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 authorSuggestion.authorSame { 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().

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.

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], using
Pliic's official PHP SDK (pliic/pliic-php) to expose suggestions and
support 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 fake
HttpClientInterface) following these rules. Flag anything that conflicts
with 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.