tapp/laravel-airtable
A Laravel package for integrating Airtable into your app. Query tables, create and update records, and map Airtable data to Eloquent-style models with a simple, fluent API for syncing and managing data.
Install and configure (under 5 minutes):
composer require tapp/laravel-airtable
php artisan vendor:publish --tag=airtable # if the tag differs, check the provider
Add credentials to .env:
AIRTABLE_KEY=pat_xxxxxxxxxxxxxxxxxxxxx # personal access token, not legacy apiKey
AIRTABLE_BASE=appXXXXXXXXXXXXXX # the base ID from the Airtable URL
The service provider and Airtable facade are auto-discovered. First sanity check:
use Tapp\Airtable\Facades\Airtable;
$records = Airtable::table('Leads')->get(); // Collection of rows
$one = Airtable::table('Leads')->find('recXXXXXXXXXXXXXX');
Where to look first:
config/airtable.php — confirm key, base, and any api_version/timeout keys match what you put in .env.src/ — the package is small; AirtableManager (or similarly named class) is the real entry point. Read it before trusting IDE autocompletion, because the public surface is thin and undocumented methods rarely exist.First real use case: pulling a reference table (e.g. "Pricing Tiers" or "Countries") into a read-only cache on boot, and pushing form submissions into a "Leads" table. Both are single-table CRUD, which is exactly what this wrapper is built for. If your first task involves complex filterByFormula, linked-record traversal, or attachments, validate early that the wrapper exposes what you need — you may end up falling back to raw HTTP.
Repository / service layer, not controllers. Wrap the facade so table names and field mappings live in one place:
class LeadRepository
{
public function all(): Collection
{
return Airtable::table('Leads')->get();
}
public function create(array $data): Collection
{
return Airtable::table('Leads')->create([
'Name' => $data['name'],
'Email' => $data['email'],
'Status' => 'New',
]);
}
}
CRUD round-trips. The typical set is get() / find($id) for reads and create() / update($id, $data) / delete($id) for writes. Record IDs are the Airtable rec… strings — store them in your own database as a mapping column so you can round-trip later.
Filtering. Use the wrapper's where/filter helpers rather than pulling all rows and filtering in PHP (Airtable caps page size at 100 records by default, so client-side filtering is both slow and wrong):
$active = Airtable::table('Leads')->where('Status', 'Active')->get();
For anything the helper cannot express, drop to a filterByFormula string and remember Airtable uses {Field Name} syntax with the braces:
Airtable::table('Leads')->where('Status', 'Active')->get();
// or, if exposed:
Airtable::table('Leads')->filterByFormula("{Status} = 'Active'")->get();
Pagination. Distinguish "one page" from "everything." get() is typically a single page (100 rows); loop with offset/limit or explicitly call the fetch-all path. For large tables, write a queued job that pages through and upserts into your own DB — do not hydrate thousands of Airtable rows in a web request.
Caching read-heavy tables. Airtable has a hard rate limit (~5 requests/second per base), so wrap stable reference data:
$tiers = Cache::remember('airtable.tiers', now()->addHour(), fn () =>
Airtable::table('Pricing Tiers')->get()
);
Bust the cache on write via a model event or a small observer around your repository.
Queue writes. Any non-interactive write (form submission, webhook handler) should be dispatched as a job with RateLimited middleware or Bus::batch so a burst of traffic does not hit Airtable's 5 rps ceiling and start returning 429s.
Testing. Use Laravel's Http::fake() to stub Airtable's api.airtable.com responses, or bind a fake into the container that returns fixture Collections. Do not hit the live base in the test suite — the rate limit will bite you in CI.
Integration into Eloquent workflows. A common pattern is a local leads table as the source of truth plus an airtable_id column, and a queued listener that mirrors changes. This gives you query flexibility and Airtable as a reporting surface, rather than making Airtable the primary datastore.
429 with a Retry-After header around 5 rps per base. The wrapper does not include retry/backoff; add ->retry(3, 1000) on the HTTP client or a RateLimited job middleware. Watch for it especially in tests and seeders that loop over records.where('status', 'Active') silently returns an empty collection if the field is Status. This is the most common "the package is broken" report — it usually isn't.get() vs all() semantics. Older versions of the package fetch a single page (max 100) with get() and may or may not auto-page on all(). Read the source or the README before assuming you receive every row; a truncation bug at 100 records is easy to ship and hard to notice.O'Brien) break filterByFormula unless escaped. Build formulas through the package's helpers where possible, or escape single quotes as \' yourself.url and filename. Do not assume scalars — the wrapper usually returns the raw JSON structure inside the Collection, so map/transform before rendering.update vs replace. Airtable has both PATCH (merge) and PUT (replace) semantics. If the package exposes update() as a PATCH, sending {Status: null} may not clear the field the way you expect; check whether clearing is supported and if you need an explicit empty value.api_key (legacy, deprecated) from personal access tokens. Modern Airtable requires PATs with per-base scopes. A 401 almost always means the token lacks the base scope or the base ID is wrong (base IDs start with app). A 404 often means the table name is misspelled rather than the base being unreachable.Illuminate\Http\Client\RequestException (or the package's exception if it defines one). Distinguish 4xx (developer error — bad field, bad base) from 5xx/429 (retryable) before scheduling retries.timeout in the config's HTTP options rather than trusting the default, and cap limit/fields to only the columns you need to speed up responses.Airtable::macro-style extension on the manager class for reusable filters. You can also bind your own repository as a singleton and inject it, keeping the facade out of application code.composer.json, add integration tests against a sandbox base, and keep a thin adapter class so you can swap to the official Airtable PHP client or raw Http::withToken() calls without touching call sites.POST /v0/{base}/{table}/listRecords paging with offset tokens, verify support first — a 20-line Http::withToken(config('airtable.key')) call is often clearer than fighting an abstraction that does not expose the primitive.How can I help you explore Laravel packages today?