heano / basin-php
A small, framework-agnostic PHP client for the Basin (usebasin.com) forms API.
Requires
- php: ^8.2
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- pestphp/pest: ^3.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client with PSR-17 factories (GuzzleHttp\Psr7\HttpFactory).
Provides
None
Conflicts
None
Replaces
None
README
A small, framework-agnostic PHP client for the Basin forms API.
- Works with any PSR-18 HTTP client, such as Guzzle or Symfony HttpClient.
- Typed exceptions for auth, not-found, validation, rate-limit and outage errors.
- Defensive parsing: copes with Basin's list wrappers, string page numbers and missing metadata.
- Strips secrets Basin returns in plain text (see What gets removed).
- PHP 8.2+, no other dependencies beyond the PSR interfaces.
It isn't an official Basin package. It's the API client from Basin for Craft, with the Craft parts taken out. The plan is for that plugin to depend on this package.
Installation
composer require heano/basin-php
You also need a PSR-18 client and PSR-17 factories. If you don't have one already, Guzzle provides both:
composer require guzzlehttp/guzzle
Getting started
Create an API key in your Basin account and keep it in an environment variable rather than in code.
use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Psr7\HttpFactory; use Heano\Basin\Client; $factory = new HttpFactory(); $basin = new Client( apiKey: getenv('BASIN_API_KEY'), http: new Guzzle(['timeout' => 10, 'connect_timeout' => 5]), requestFactory: $factory, streamFactory: $factory, );
Timeouts, proxies and retries belong to the HTTP client, so set them there. A fifth argument, baseUrl, defaults to https://usebasin.com/api/v1/; change it only to point at a proxy or a mock server.
Usage
Projects and forms
$projects = $basin->listProjects(); foreach ($projects->items as $project) { echo $project['id'], ': ', $project['name'], PHP_EOL; } $project = $basin->getProject(1004); // Forms in one project. Leave the project ID out to list every form. $forms = $basin->listForms(projectId: 1004); // One form, with its inbox_count, spam_count and trash_count. $form = $basin->getForm(1001); echo $form['uuid']; // e.g. "demo0contact", the ID in https://usebasin.com/f/demo0contact // Change a form's settings, using Basin's field names. $basin->updateForm(1001, ['use_ajax' => false]);
updateForm() only changes the fields you pass. Basin ignores field names it doesn't know, without an error, so check the spelling against Basin's API docs.
Submissions
$page = $basin->listSubmissions([ 'formId' => 1001, // or 'projectId' => 1004 'filter' => null, // null (inbox), 'spam' or 'trash' 'query' => 'jane', // Basin's text search 'order' => 'date_desc', // date_desc, date_asc, email_asc, email_desc 'dateFrom' => '2026-01-01', 'dateTo' => '2026-01-31', // either end can be left out ]); $page->items; // the submissions $page->total; // matching submissions, if Basin said $page->meta['inbox_count']; // Basin's counts for the same scope $page->meta['spam_count']; $submission = $basin->getSubmission(5678); $submission['payload_params']; // the submitted fields // Mark as read, spam or trash (any combination). $basin->updateSubmission(5678, ['read' => true]); $basin->updateSubmission(5678, ['spam' => false, 'trash' => true]); // Send it to the form's webhooks again. $basin->refireWebhooks(5678); // Delete it permanently. This can't be undone. $basin->deleteSubmission(5678);
Unknown filters and orders throw an InvalidArgumentException before anything is sent, because Basin ignores an unknown filter_by and returns everything, spam included. For the same reason, updateSubmission() only accepts read, spam and trash.
Paging
Basin returns 100 items per page. Each list method takes a page number and returns a ListPage:
$page = 1; do { $result = $basin->listSubmissions(['formId' => 1001], $page++); foreach ($result->items as $submission) { // ... } } while ($result->hasMore());
Attachments
Each attachment in a submission has an id. Download it through Basin's authenticated proxy:
$attachment = $submission['attachments'][0]; $response = $basin->downloadAttachment($attachment['id']); header('Content-Type: ' . $attachment['content_type']); header('Content-Disposition: attachment; filename="' . addslashes($attachment['name']) . '"'); echo $response->getBody();
The PSR-7 response is returned as is, so the body can be streamed if your HTTP client supports it (Guzzle's stream option, for example). The method also accepts the last segment of an attachment's attachment_proxy_url.
Errors
Every API error extends Heano\Basin\Exception\ApiException. The exception code is the HTTP status, and messages never contain the API key.
| Exception | When |
|---|---|
AuthException |
401 or 403, or no API key was given |
NotFoundException |
404 |
ValidationException |
422 (Basin's message is used where there is one) |
RateLimitException |
429. $e->retryAfter holds the seconds to wait, if Basin said |
ApiUnavailableException |
5xx, timeouts and connection failures |
ApiException |
Anything else, including responses that aren't valid JSON |
use Heano\Basin\Exception\ApiException; use Heano\Basin\Exception\NotFoundException; try { $form = $basin->getForm(1001); } catch (NotFoundException) { $form = null; } catch (ApiException $e) { error_log('Basin: ' . $e->getMessage()); }
What gets removed
Basin's API returns some data in plain text that most applications never need and shouldn't store, log or show. The client removes it from every response before it reaches your code:
- Forms:
turnstile_secret; every email and notification setting (which hold people's addresses); andform_webhooks, whose URLs often contain secrets. It's replaced withwebhook_count. - Submissions: attachment
public_urls, which work for anyone who has them. UsedownloadAttachment()instead.
Endpoints covered
| Method | Endpoint |
|---|---|
listProjects() |
GET /projects |
getProject() |
GET /projects/{id} |
listForms() |
GET /forms |
getForm() |
GET /forms/{id} |
updateForm() |
PUT /forms/{id} |
listSubmissions() |
GET /submissions |
getSubmission() |
GET /submissions/{id} |
updateSubmission() |
PATCH /submissions/{id} |
deleteSubmission() |
DELETE /submissions/{id} |
refireWebhooks() |
POST /submissions/{id}/refire_webhooks |
downloadAttachment() |
GET /attachments/{id} |
There's deliberately no "request anything" method. Basin's API documentation covers the rest of the API.
Development
composer install
composer test
No PHP locally? Use Docker:
docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app php:8.2-cli vendor/bin/pest
The tests use mocked HTTP and never call Basin. Fixtures are scrubbed responses from a test account.
Licence
MIT. See LICENSE.