yoanbernabeu/airtable-client-bundle
Symfony bundle that wraps the Airtable API in a simple client service, letting you configure your API key and base, then list, create, update, and delete records with clean, injectable PHP code.
1. Install
composer require yoanbernabeu/airtable-client-bundle
Heads-up before you start: despite the name, this is a Symfony bundle, not a Laravel package. It ships a Bundle class and Symfony DI extension, but no ServiceProvider, no facade, and no config/airtable.php publish tag. Composer will pull symfony/http-kernel in as a dependency, which is harmless, but nothing auto-registers. You are using it as a library and wiring it yourself.
2. Configure via env
The bundle reads an API key from the environment. In Laravel, add to .env:
AIRTABLE_API_KEY=patXXXXXXXXXXXXXX
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
Use a Personal Access Token, not a legacy API key — the legacy keys are deprecated. Scope the token to only the bases and only the operations (data.records:read, data.records:write) you actually need.
3. Register a singleton in a service provider
// app/Providers/AirtableServiceProvider.php
public function register(): void
{
$this->app->singleton(\Airtable\Client::class, fn () => new \Airtable\Client(
config('services.airtable.key'),
config('services.airtable.base')
));
}
Add the matching entry to config/services.php so you can use config() and benefit from config caching. Skip the bundle's Symfony config tree entirely — trying to make config/packages/airtable.yaml work in Laravel is wasted effort.
4. First use case
The highest-value first use is read-only: pull a table into a view or an artisan command. Inject the client, call the table, iterate pages, map to a DTO or array. Do not start with writes — get read + pagination + error handling right first, because that's where 90% of the friction lives.
Where to look first: the underlying Airtable client's README (the bundle is a thin wrapper), then the bundle's DependencyInjection/Configuration.php for the exact env var names and defaults, then the src/ service definitions to see what's actually autowirable. The bundle source is small — read it in one sitting.
Wrap it in your own repository class. Never inject the raw client into controllers or jobs. One class per table gives you a typed boundary and a single place to change when field names move:
class LeadsTable
{
public function __construct(private \Airtable\Client $client) {}
public function all(): Generator
{
$offset = null;
do {
$page = $this->client->table('Leads')->list([
'pageSize' => 100,
'offset' => $offset,
]);
foreach ($page->records as $record) {
yield $record;
}
$offset = $page->offset ?? null;
} while ($offset);
}
}
Yielding keeps memory flat — relevant once a base passes a few thousand rows, since the client holds full pages in memory.
Create/update via a "record payload" method. Keep Airtable's fields envelope out of your call sites:
public function upsert(Lead $lead, ?string $airtableId = null): string
{
$fields = ['Name' => $lead->name, 'Email' => $lead->email];
return $airtableId
? $this->client->table('Leads')->update($airtableId, $fields) // 1.1.0 edit support
: $this->client->table('Leads')->create($fields);
}
Version 1.1.0 specifically adds record editing (Features/edit record, PR #33). Before this, updating a record meant either dropping to the underlying client directly or hand-rolling a PATCH through Guzzle. Now the bundle exposes it, so update/edit becomes a first-class call alongside create. This is additive and non-breaking — the practical effect is that you no longer need a raw-HTTP escape hatch for the most common write operation, and you can keep your whole CRUD surface on one abstraction. If you previously wrote a custom patchRecord() helper, delete it.
Queue your writes. Airtable enforces roughly 5 requests/second per base. A synchronous loop creating 200 records will get you 429s. Push each write onto a queue with a rate limiter:
RateLimiter::for('airtable', fn () => Limit::perSecond(4)->by('airtable'));
Wrap the job body in the limiter middleware. Reads are safer to do inline, but anything user-triggered that fans out over a table belongs in a job.
Idempotency for syncs. Airtable has no natural upsert. Store the returned record ID (recXXXX) on your Eloquent model and branch on its presence. For bulk imports, list the table once, build an index keyed on your natural key, then decide create-vs-update per row — that halves your API calls and avoids duplicates on retry.
Testing. Bind a fake client in the container and assert on the argument arrays. For integration-ish tests, point the client's HTTP layer at a recorded fixture set with Http::fake()-style stubbing or a Guzzle mock handler, so you never hit a live base in CI. Keep one manually-run smoke command that hits a scratch base for verifying field-name changes.
Local development. Put a NullAirtableClient behind the same interface and bind it in local, or point at a dedicated sandbox base. Nothing is more annoying than a team of developers all writing test rows into the production CRM.
The Symfony/Laravel mismatch is the biggest one. No auto-discovery, no facade, no publish command, and the bundle's config tree won't load. You get the container binding you wrote and nothing else. Version bumps to the bundle can change the Symfony extension in ways that are invisible to you — pin the version in composer.json and read the changelog before upgrading.
Field names are not field IDs. Airtable accepts either, but renames break name-based code silently-ish (you get a 422 UNKNOWN_FIELD_NAME). Field IDs (fldXXXX) are stable across renames. For anything long-lived, store a name→ID map in config and translate your domain keys through it.
422 is usually your fault, and the message names the field. Read the error.type — INVALID_MULTIPLE_CHOICE_OPTIONS means the select option doesn't exist yet; pass typecast => true to let Airtable create it. INVALID_VALUE_FOR_COLUMN is usually a type mismatch (string sent to a number field). Log the full response body on 4xx; the bundle may not surface it.
Clearing a field is not "". Airtable distinguishes "empty" from "unset". Sending '' clears single-line text but not linked records or selects — send null for those. Sending nothing leaves the value untouched. There's no "clear all" shortcut.
filterByFormula is a string-escaping minefield. Values go inside {Field}='value' with single-quote escaping via \'. Never interpolate user input raw. Better: use filterByFormula only for simple cases and filter in PHP when the table is small — debugging a formula that returns zero rows is far more expensive than one extra page of data.
Batch limits are easy to trip. pageSize caps at 100. Batch create caps at 10 records per request. If you built a chunking loop assuming 100, you'll get 422s under load.
Attachments require public URLs. You cannot upload a file body — you pass [['url' => 'https://...']]. Files on a private S3 bucket won't work without a signed, publicly reachable URL, and Airtable may re-fetch the URL asynchronously. Generate signed URLs with a generous TTL.
Deleted vs empty on read. Reading a record you deleted returns 404; reading a record whose field was cleared returns null or omits the key entirely depending on the field type. Normalise to a default in your mapper instead of sprinkling ?? null everywhere.
Debugging. This is a thin wrapper, so the fastest path is to log the outgoing request. Drop in a Guzzle middleware that logs method, path, and body, and register it only when app()->hasDebugModeEnabled(). On 429s, log the Retry-After header — Airtable sends one, and honouring it beats blind exponential backoff.
Extension points are thin but sufficient. The client takes an underlying HTTP handler, so retry/rate-limit middleware is where you add resilience. To add caching, decorate your own repository class rather than the client — the client has no cache layer and no per-table hooks, so decorating the wrapper you already own is cleaner and doesn't depend on the bundle's internals.
How can I help you explore Laravel packages today?