tomatophp/tomato-locations
A Laravel package for managing geographic locations such as countries, states, cities, and areas. It ships with ready-made migrations, Eloquent models, and seeders, plus optional Filament admin integration, making it easy to add structured location data and dropdowns to your app.
Assumptions before you start: you are on a Laravel app with Filament 3 and, ideally, the TomatoPHP admin panel installed. The data layer (models + migrations) is plain Eloquent and will work without Filament; the admin screens will not.
Install and publish
composer require tomatophp/tomato-locations:^1.2
The service provider auto-discovers. Then publish and run the migrations:
php artisan vendor:publish --tag=tomato-locations-migrations
php artisan vendor:publish --tag=tomato-locations-config
php artisan migrate
Where to look first
Open vendor/tomatophp/tomato-locations/ and read, in order:
database/migrations/ — this is the contract you are about to adopt. Note every table name, column name, and unique index. You will be joining against these for years.src/Models/ — Country, City (and any Area/State equivalents). Confirm the actual class names and namespace; they are what you will reference everywhere.src/Filament/ (or src/Tomato/) — confirms the admin route slugs and permission keys.config/tomato-locations.php — table names, model bindings, feature flags.Do this before migrate, not after. If the app already has a countries or cities table, stop and read the schema diff first.
First use case: attach a location to an existing model
Do not edit the published migrations. Add your own migration with a foreign key:
Schema::table('users', function (Blueprint $table) {
$table->foreignIdFor(\TomatoPHP\TomatoLocations\Models\Country::class)
->nullable()
->after('email')
->constrained()
->nullOnDelete();
});
Then on the model:
use TomatoPHP\TomatoLocations\Models\Country;
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
And in the Filament form:
Select::make('country_id')
->relationship('country', 'name')
->searchable()
->preload()
->required(),
At this point you have a working country dropdown backed by seeded reference data. Everything else in this assessment is about not regretting it later.
Seeding — do it as a background job, not a deploy hook
A full city/area dataset is minutes of writes. Never run it from a migration, a post-deploy script, or a request. Register it as a queued job:
php artisan queue:work --queue=imports
and dispatch your seed/refresh job onto that queue. See the pattern in the next section.
This is the single highest-leverage thing you can do. The package is easy to swap if nothing in your codebase imports the models directly outside one directory.
namespace App\Support\Locations;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use TomatoPHP\TomatoLocations\Models\Country;
use TomatoPHP\TomatoLocations\Models\City;
class LocationRepository
{
public function countries(): Collection
{
return Cache::remember(
'locations:countries',
now()->addHours(12),
fn () => Country::query()->orderBy('name')->get(['id', 'name', 'iso2'])
);
}
public function cities(int $countryId): Collection
{
return Cache::remember(
"locations:cities:{$countryId}",
now()->addHours(12),
fn () => City::query()
->where('country_id', $countryId)
->orderBy('name')
->get(['id', 'name'])
);
}
public function resolve(int $countryId, int $cityId): array
{
$country = Country::findOrFail($countryId);
$city = City::where('country_id', $countryId)->findOrFail($cityId);
return [
'country_id' => $country->id,
'country_name' => $country->name,
'city_id' => $city->id,
'city_name' => $city->name,
];
}
}
Bind it in a service provider. When you later add a validation/geocoding provider, one class changes.
For profiles, warehouses, store locators — store the foreign key. You want the value to update when someone fixes a typo in a city name.
For orders, invoices, shipments, tax records — store the FK and a denormalized name snapshot written at creation time:
$order->forceFill($locations->resolve($request->country_id, $request->city_id))->save();
Reference data changes. A 2024 invoice must still read "Kyiv, Ukraine" even if the reference row is renamed or removed. Without the snapshot you have an unfixable historical-data problem.
Chain the selects with live(), so changing the country clears and reloads cities:
Select::make('country_id')
->options(fn () => app(LocationRepository::class)->countries()->pluck('name', 'id'))
->searchable()
->live()
->afterStateUpdated(fn (Set $set) => $set('city_id', null))
->required(),
Select::make('city_id')
->options(fn (Get $get) => $get('country_id')
? app(LocationRepository::class)->cities($get('country_id'))->pluck('name', 'id')
: [])
->searchable()
->disabled(fn (Get $get) => blank($get('country_id')))
->required(),
Use ->options() from the cached repository, not ->relationship(), on large tables — relationship selects re-query on every keystroke and will show up in your query log.
Reference data goes stale: city renames, ISO code changes, country splits. Define the refresh path on day one, or nobody will ever do it.
class RefreshLocationsJob implements ShouldQueue
{
public $timeout = 900;
public function handle(): void
{
DB::transaction(function () {
foreach (array_chunk($this->countries, 500) as $chunk) {
Country::upsert($chunk, ['iso2'], ['name', 'phone_code']);
}
// cities keyed on (country_id, name) or an external source id
foreach (array_chunk($this->cities, 500) as $chunk) {
City::upsert($chunk, ['country_id', 'name'], ['updated_at']);
}
});
Cache::forget('locations:countries');
Cache::flush(); // or iterate known keys if flush() is too broad for you
}
}
Use upsert keyed on a stable identifier (ISO code / source ID), never truncate + insert — truncation breaks every FK pointing at the table and will fail or cascade destructively.
Register any Filament resource or plugin in config/tomato-php.php (or your panel provider's ->plugins([...])), and add navigation/permission entries alongside the rest of your Tomato plugins. Then create the matching permission records so tomato-roles can gate countries.view, countries.update, etc. Seed those permissions in your own seeder — do not assume the package ships them for your role set.
Since the package ships seed data but not test factories, write your own:
Country::factory()->create(['name' => 'Utopia', 'iso2' => 'UT']);
Never let feature tests depend on the real seeded dataset — it makes tests slow and geographically fragile.
Published migrations are your schema, not the vendor's. As soon as you run vendor:publish, that file is first-party code under review. Do not require the vendor migration path and do not let a composer update silently alter a countries table your orders join against.
Table-name collisions are the number-one install failure. The package's migrations assume countries / cities are free. Run migrate against a restored production snapshot on a feature branch, not against a fresh local DB. If a table exists, decide now: rename via config (if supported) or rename yours. This is much cheaper before you have address rows.
Seeding produces a bad failure mode: dropdowns that are silently empty. A chunked job that dies halfway leaves a partially populated hierarchy; the UI then looks "broken" rather than "incomplete." Log row counts per country and add a smoke test that asserts Country::count() > 0 and that your largest market has cities. Make the seeder resumable.
Never lazy-load the hierarchy in a list. $order->city->country->name in a table of 50 rows is 100 queries. Eager-load (with('city.country')) and, for admin tables, prefer formatStateUsing against a cached lookup map.
Cache invalidation is manual. There is no built-in cache layer here — the package is plain Eloquent. If you cache, you own clearing it on re-seed. Stale country lists after a refresh are a classic "works locally" bug.
Deletes cascade into your data. Reference rows are joined by orders, warehouses, and tax rules. Set restrictOnDelete() (or nullOnDelete() for soft cases) on your own FKs rather than trusting defaults, and forbid deleting locations from the admin UI if they are in use. Make "hide/deactivate" the soft-delete path instead.
Patch releases here come with no changelog. The v1.2.5 release note is only a compare link. Before any bump: git diff v1.2.4..v1.2.5 -- database/ src/Models/ and confirm nothing changed in migrations, model class names, or config keys. Any such change at a patch level is a semver violation — pin exactly and note it.
Pin ^1.2 — never * and think twice about dev-main. With zero dependents, you are the de facto QA department. Treat every upgrade as a review, not an automated merge.
Filament major-version skew is the likely breakage point, not the models. The geo models are plain Eloquent; the admin resource classes are the fragile part. If your Filament major drifts ahead of this package, you can keep the data layer and drop the UI — verify that the package's Filament resources live in a separate namespace so an app can simply not register them.
Optional areas/states. Do not assume every country has a state or area level populated. Make those optional in your schema and your UI (conditional visible() on the state select), or your forms break for countries outside the seeded set.
Store ISO codes, render names. Never join or branch logic on a display name. iso2/iso3 are the stable key; names get renamed, translated, and politically contested. If any of your markets are disputed regions, decide the labeling policy at the data layer, not in a Blade template.
Data provenance and licensing are undocumented. The origin and license of the bundled seed set are not stated in the release notes. If this data ever becomes customer-facing, get a definitive answer before launch — and record it in the repo. Meanwhile, avoid treating the seed as authoritative for anything legally or financially binding (tax jurisdiction determination being the obvious one).
Extension points, in order of safety: your own migrations adding columns/FKs → subclassing the models if you need extra behavior → writing your own Filament resource instead of using theirs → forking. Forking last. The whole package is a few thousand lines; if upstream stalls, vendoring it is an afternoon, which is exactly why the adoption risk here is manageable — provided you keep the import surface confined to one repository class.
Debugging aid: add a tiny artisan command that prints counts per level (countries, cities, areas) and the last seed timestamp. It answers 80% of "the dropdown is empty" tickets in ten seconds.
How can I help you explore Laravel packages today?