milenmk/laravel-locations
Laravel package for adding countries, states/regions, and cities to your app. Provides migrations, Eloquent models, seeders, and lookup helpers for building address forms, location selects, and searchable location data.
Install the package and publish its assets through Composer, then expose the config, migrations, and seeders:
composer require milenmk/laravel-locations
php artisan vendor:publish --provider="Milenmk\LaravelLocations\LaravelLocationsServiceProvider"
php artisan migrate
The published migrations create tables for countries, cities, areas, languages, and currencies. Before running migrations in an existing app, check the database/migrations folder (or the package's migrations/ directory) to see the exact table names and confirm they don't collide with anything you already have.
Next, seed the reference data. This is the step people forget — the tables are empty until you run the seeders, so you'll see empty dropdowns otherwise:
php artisan db:seed --class="Milenmk\LaravelLocations\Database\Seeders\LocationSeeder"
Look first at the package's src/Models directory (or the published models) to understand the relationships — Country typically has many City and Area records, and belongs to Currency/Language through codes. Read the migration files before seeding: the cities table can contain tens of thousands of rows, so you'll want to know what you're loading into your dev DB.
Your first real use case is almost always a country dropdown on a signup or address form. Once seeded, it's simply:
use Milenmk\LaravelLocations\Models\Country;
$countries = Country::orderBy('name')->pluck('name', 'id');
Return that to your view/Inertia props and you have a working selector in minutes.
Cascading selects (country → city/area). The common pattern is an endpoint that returns cities filtered by country, then areas filtered by city:
Route::get('/countries/{country}/cities', function (Country $country) {
return $country->cities()->orderBy('name')->get(['id', 'name']);
});
Trigger it from a change handler on the country select. If you're on Livewire, bind $countryId as a property and use updatedCountryId() to refetch cities — the reactive model maps naturally onto this data.
Eager-load to survive the N+1 trap. Any listing that shows a country alongside a city (e.g. a user directory) will explode in queries if you don't eager-load:
User::with('city.country', 'country.currency')->paginate();
Store codes, not names. Persist country_code (ISO alpha-2), currency_code, and language_code on your own tables rather than the package's numeric IDs. This keeps your data portable if you swap packages later and makes joins to the package tables by code trivial. It also avoids breaking if the package re-seeds and IDs shift.
Validate against the package tables. Rather than a long hardcoded in: list, use the exists rule:
'country_id' => ['required', 'exists:countries,id'],
'city_id' => ['required', 'exists:cities,id'],
Cache the static lists. Countries, languages, and currencies barely change — cache them for the life of a deploy:
$countries = Cache::rememberForever('countries', fn () =>
Country::orderBy('name')->get(['id', 'name', 'code'])
);
Invalidate the cache in a deploy hook or after re-seeding. Cities/areas are too large to cache wholesale; cache per-country instead if you need to.
Use it as a lookup, not a source of truth for business logic. Treat the package's tables as read-mostly reference data. If you need to store the user's chosen country with extra metadata (e.g. their billing region), keep your own user_addresses table and FK into countries rather than adding columns to the package's tables.
cities (and possibly areas) seeders can run for minutes and add significant row counts. In CI, seed a single country for tests rather than the full dataset, or use RefreshDatabase with a trimmed seeder.insert rather than upsert, so re-seeding can create duplicates or throw unique-constraint errors. Guard with Country::count() === 0 in your own deploy script, or wrap the seeder call in a "run once" conditional.cities references countries, running them out of order fails. Use the package's aggregate seeder or run countries → currencies/languages → cities → areas explicitly.countries table (common in older apps), the package migration will fail. Rename your table, or skip the package migration and map the models to your existing schema.us, gb). Normalize with strtoupper()/strtolower() on both sides when joining to your own data or to third-party APIs.name column plus translations), verify how locale is selected. If translations aren't included, override display names in your own translation files keyed by country code instead of patching the package.Country::factory() only if the package provides one; otherwise seed a fixture row manually in setUp() or create a tiny helper trait. Avoid depending on specific seeded IDs in assertions.composer.json and consciously re-run seeders after upgrades — a naive composer update + deploy won't refresh rows unless you re-seed.class Country extends \Milenmk\LaravelLocations\Models\Country) and add scopes/accessors there; bind your subclass in a service provider if the package resolves models by class name.How can I help you explore Laravel packages today?