ijeffro/laravel-airports
Laravel Airports provides IATA codes, ISO 3166-3 and country codes for airports, plus a service provider, facade, migration and seeder to populate an airports table. Install via Composer, publish config, migrate and seed for searchable airport data.
1. Confirm which package you're actually installing. The repository carries two generations of docs: the original ijeffro/laravel-airports (Laravel 5, dev-master, manual provider/alias registration) and the v1.0.0 rewrite (FLAIRUK\Airports, PHP 8.2+, Laravel 12/13, auto-discovery). They are not interchangeable — the namespace, service provider, and API all changed. Pick one and stick to it:
# New line (Laravel 12/13)
composer require flairuk/laravel-airports
# Legacy line (Laravel 5–11, pinned)
composer require ijeffro/laravel-airports:dev-master
Check exactly what landed before writing code:
composer show flairuk/laravel-airports # or ijeffro/laravel-airports
2. Skip the database on day one. The whole point of the v1.0.0 rewrite is that lookups are an in-memory PHP array — no migration, no seeding, no cache. Your first useful line is:
use FLAIRUK\Airports\Facades\Airports;
$airport = Airports::find('JFK'); // ?Airport (readonly object, not an array)
$airport?->iata; // 'JFK'
$airport?->name; // 'John F Kennedy Intl'
$airport?->city; // 'New York'
$airport?->country; // 'US'
3. First real use case: form validation. If you've been hand-rolling exists:airports,iata or a regex, swap in the shipped rule — it is backed by the in-memory dataset and therefore works with zero infrastructure:
use FLAIRUK\Airports\Rules\AirportCode;
$request->validate([
'departure_iata' => ['required', 'string', new AirportCode()],
'arrival_iata' => ['required', 'string', new AirportCode()],
]);
4. Add the table only when you have a reason. If you need SQL JOINs, aggregates, or FK constraints on departure_iata, then:
php artisan vendor:publish --tag=airports-migrations
php artisan airports:install # migrate + idempotent seed
5. Where to look first in the codebase. The README is thin on signatures, so read the facade/service class and the Airport value object directly (vendor/flairuk/laravel-airports/src/) and confirm method names in php artisan tinker before spreading them across controllers. Treat the documented surface (find, findOrFail, exists, inCountry, search, options) as the intended API and verify each one you depend on.
All of these are pure array operations against a compiled dataset — no I/O, no cache layer:
| Call | Returns | Use for |
|---|---|---|
find('LAX') |
?Airport |
Optional lookups, display hydration |
findOrFail('LAX') |
Airport or throws |
Importers, queued jobs — fail loudly on bad data |
exists('LAX') |
bool |
Guard clauses, pre-flight validation of bulk input |
inCountry('GB') |
collection of Airport |
Country-scoped selects, regional reporting |
search('Heathrow') |
collection | Typeahead / autocomplete endpoints |
options() |
['JFK' => 'John F Kennedy Intl (New York)', ...] |
<select> inputs |
Because there's no query cost, delete the Cache::remember() wrappers you'd normally put around airport dropdowns. They add invalidation bugs for no gain. Cache only at the HTTP layer when you serialize large lists into an API response.
IATA codes are conventionally uppercase; user input is not. Do it in the form request, not scattered around:
protected function prepareForValidation(): void
{
$this->merge([
'departure_iata' => strtoupper(trim((string) $this->departure_iata)),
'arrival_iata' => strtoupper(trim((string) $this->arrival_iata)),
]);
}
public function __invoke(Request $request)
{
$term = trim((string) $request->string('q'));
if (strlen($term) < 2) {
return AirportResource::collection(collect());
}
return AirportResource::collection(
Airports::search($term)->take(15)
);
}
Bound the result set before it crosses the wire — search() on a large dataset can return thousands of matches, and options() returns the full list (~10k entries). Never render or JSON-encode options() unfiltered on a public page; filter by country or city first.
The Airport object is readonly. Every 0.x array idiom ($airport['city'], ->merge(), ->toArray()) breaks. When you need computed or localized fields, decorate:
final class AirportView
{
public function __construct(
public readonly Airport $airport,
public readonly ?float $distanceKm = null,
) {}
public function label(): string
{
return "{$this->airport->name} ({$this->airport->iata})";
}
}
The classic failure is a half-written import where row 400 has a typo. Use the in-memory index as a cheap pre-check before touching the DB:
$codes = collect($rows)
->pluck('iata')
->map(fn ($c) => strtoupper(trim((string) $c)));
$unknown = $codes->reject(fn ($c) => Airports::exists($c))->unique()->values();
abort_if($unknown->isNotEmpty(), 422, 'Unknown IATA codes: '.$unknown->implode(', '));
Wrap this in a custom rule or a form request so it also runs for API clients.
With the migration published you get a model suitable for joins:
public function departureAirport()
{
return $this->belongsTo(\FLAIRUK\Airports\Models\Airport::class, 'departure_iata', 'iata');
}
Rule of thumb: Eloquent for sets (whereIn, joins, GROUP BY country reports), facade for single codes in domain logic. Don't alternate between the two for the same decision — they are separate sources of truth.
Prefer the dedicated command over calling the seeder class manually:
php artisan airports:seed # documented as idempotent — safe on every deploy
Put it after migrate in your deploy script. If you add rows your app owns (disabled airports, custom display names), keep them in your table or a pivot, not in the package table — see Gotchas.
Domain tests need no database at all:
it('rejects unknown airports', function () {
expect(Airports::exists('ZZZ'))->toBeFalse();
});
Add RefreshDatabase only to feature tests that touch the published table or the Eloquent model. Keep a fixture of a handful of known-good codes (JFK, LHR, NRT) rather than asserting on dataset row counts — the dataset will drift between releases.
1. The two READMEs describe different packages. Before you copy any snippet, note the namespace: ijeffro\Airports\AirportsServiceProvider vs FLAIRUK\Airports\.... On v1.0.0, remove any legacy providers/aliases entries from config/app.php — a cached config pointing at the old namespace produces "class not found" errors that survive composer update. After upgrading:
composer dump-autoload
php artisan config:clear && php artisan cache:clear
2. Objects, not arrays. This is the #1 breakage when migrating from 0.x. Grep for getByIata, getByName, whereCountry, ['city'], and ->merge( across your codebase and convert them to find() plus null-safe property access.
3. Case and whitespace. find('jfk') / find(' JFK') may not resolve. Normalize once at the request boundary — cheapest place to fix a bug that's otherwise annoying to trace.
4. Sparse records exist. city, region, and coordinates can be null or empty on smaller airfields. Always ?-> / ?? '—' in Blade, API resources, and any distance calc — a null latitude will blow up a haversine.
5. Two sources of truth can drift. The in-memory array and the published airports table are independent. airports:seed refreshes the table; if you've edited rows locally, a re-seed may overwrite them. Pick one as authoritative per use case and write it down — the facade for reference data, the table for anything the app mutates.
6. Memory and opcache in long-lived processes. The dataset ships as an opcache-friendly PHP array — fast, but not weightless. Under Octane or many queue workers, each process holds its own copy. Usually fine; measure if you run dozens of workers per box. Also avoid json_encode(Airports::options()) on every request.
7. Validation failing on a valid code. Distinguish "not in the dataset" from "wrong class resolved". Test directly in tinker:
Airports::exists('JFK'); // true = dataset fine, look at your rule binding
8. Table/command problems. If airports:seed reports success but queries return nothing, check three things in order: the migration actually published (tag names have changed between releases), the config table name matches what you publish, and your belongsTo foreign key matches the actual column — inspect the schema rather than trusting 0.x-era column guesses (region may not exist).
9. Facade binding errors. If Airports::find() throws, either the alias doesn't match the installed FQCN or auto-discovery is disabled (dont-discover in composer.json). Fall back to constructor-injecting the underlying service class, or register the provider explicitly. Injecting a thin wrapper service is the better long-term pattern anyway — it keeps the vendor namespace out of your controllers and gives you a seam for tests.
10. Extension points, in preference order:
Airport objects compose cleanly into DTOs, API resources, and view models.lang/ files and resolve at render time; never rewrite the package dataset.inCountry()/search() with your authorization scope and inject that, so the facade appears in one place.vendor/.11. Legacy stay-or-go. For Laravel 5–11 there is no in-place upgrade path to v1.0.0. Either stay pinned on the old dev-master (accepting the facade/array API and Laravel 5-only support), or budget a real migration: namespace swap, provider removal, array→object call-site rewrite, and re-validation of every exists:airports,iata rule.
How can I help you explore Laravel packages today?