nnjeim/world
Laravel package offering a comprehensive dataset of countries, states, cities, timezones, currencies, languages, and IP geolocation. Access it all through the World facade or ready-made API routes.
composer require nnjeim/world
php artisan world:install
world:install publishes config/world.php, runs the package migrations and executes WorldSeeder for you. If it refuses to run (the README explicitly tells you to set APP_ENV=local first), fall back to the manual path — it is the same three steps and is what you should script in CI anyway:
php artisan vendor:publish --tag=world --force
php artisan migrate
php artisan db:seed --class=WorldSeeder
Then verify with a single call. The facade is the fastest way to confirm the data landed:
use Nnjeim\World\World;
$action = World::countries(['fields' => 'id,name,iso2']);
if ($action->success) {
$countries = $action->data; // Illuminate\Support\Collection
}
Every action returns the same envelope: success (bool), message (string), data (Collection), errors (array). Write one helper that unwraps it and you have a consistent contract across all six entities.
First use case: a country/state/city cascading select. World::countries(['fields' => 'id,name,iso2']) for the first dropdown, then World::states(['filters' => ['country_code' => $iso2]]), then World::cities(['filters' => ['state_id' => $id]]). That is 90% of what most teams actually install this package for.
Where to look first: the published config/world.php (route prefix, middleware, enabled modules, localization, DB connection) and the migrations, because the foreign-key shape between world_states, world_cities and world_countries dictates how you write filters. Don't assume it from the README — read the migration.
Facade in a controller, thin and defensive. The action object never throws for "no results" — it returns success: true with an empty collection — so guard on emptiness, not just success:
$action = World::countries([
'fields' => 'id,name,iso2,phone_code,currency',
'filters' => ['region' => 'Europe'],
'search' => $request->string('q')->toString(),
]);
return $action->success
? response()->json($action->data)
: response()->json(['errors' => $action->errors], 422);
Inject WorldHelper instead of the facade in services, jobs and anything you want to unit-test. The facade resolves to the same object, but constructor injection lets you swap or mock it.
fields is the projection, filters is the predicate, search is the fuzzy match. fields is a comma-separated string; filters is an array. Relations are requested by name inside fields ('fields' => 'iso2,states,cities'), so the same parameter controls both columns and eager-loads. Always include id in fields if the response feeds another query or a form — the package only returns what you asked for.
Use the API routes as your read layer for SPAs. /api/countries?fields=iso2,cities&filters[phone_code]=44 is a complete, paginated-free endpoint you did not have to write. Point a Select2/Autocomplete at it, and keep the prefix configurable so admin or a versioned api/v1 works without code changes. Disable the routes entirely in config/world.php if the endpoints should not be public.
Drop to Eloquent when you need SQL the DSL cannot express. The models (Nnjeim\World\Models\Country, State, City, …) are ordinary Eloquent models: Country::with('states')->whereIn('iso2', $codes)->get(). This is the right tool for joins, aggregates, whereHas, or exporting — the action layer is a convenience over the models, not a replacement.
Cache the static reference data. Countries, currencies, timezones and languages change on the order of months. Cache::remember('world.countries', now()->addDay(), fn () => World::countries(['fields' => 'id,name,iso2'])->data) removes a surprising amount of query load from every page that renders a country picker. Do not cache cities wholesale — that table is very large; cache per-state if you must.
Geolocate once per session, then store the ID. World::geolocate() returns country.id, state.id, city.id linked to your own tables, so persist country_id on the user/session and use World data for the currency and timezone afterwards. Never call it on every request — see the rate limit in the next section.
$location = World::geolocate(['ip' => $request->ip()]);
$countryId = $location->success ? ($location->data['country']['id'] ?? null) : null;
Derive validation rules from the database. Rule::in(Country::pluck('iso2')) beats a hardcoded 250-entry array that silently rots. Same for currency codes and phone codes.
APP_ENV=local is a hard gate, not a suggestion. The install path expects it. In staging/CI, either set it for the one command or use the three manual steps — and never let a deploy pipeline casually flip the environment variable for the whole app.--force overwrites config/world.php. Keep your customizations in .env or in a thin config/world-custom.php you merge at boot, or you will lose them on the next composer update.max_execution_time/memory limit, disable the query log, and wrap it in a transaction only if your engine tolerates a transaction that big (MySQL InnoDB with a huge city insert may not). Timezones, currencies and languages are trivial by comparison.search is a leading-wildcard LIKE. It will not use an index on world_cities. On big tables prefer exact filters (country_code, state_id) and reserve search for small, user-typed dropdowns with a minimum length.country_code works on states/cities, country_id works on cities/timezones — mixing them up returns an empty data array rather than an error, which is the classic two-hour debugging session. Log $action->data->count() while wiring filters.routes.middleware and routes.prefix in the config and add throttle: yourself. An unthrottled /api/cities is an easy accidental DoS on your own database.ip-api.com tier, and it is a third-party dependency in your request path. Cache the result per IP (a day is reasonable), and set WORLD_GEOLOCATE_FALLBACK_API=false in production after downloading the GeoLite2 database with php artisan world:geoip --license=....CF-Connecting-IP, X-Forwarded-For, X-Real-IP, CLIENT-IP in order. Behind Cloudflare/nginx that is correct; exposed directly, any client can claim any IP. Configure Laravel's TrustProxies middleware to match your actual topology before relying on World::geolocate() for anything security-adjacent.id fields are nullable by contract. If the returned city isn't in your seeded dataset, id is omitted and you get raw geo data only. Always null-coalesce rather than assuming the key exists.WORLD_DB_CONNECTION is great until you join. Putting the large geo tables on a separate read connection keeps your primary database lean, but cross-connection joins with your users table will fail. Keep the ID linkage in your own tables and join in PHP, or keep everything on one connection.Http::fake() (or disable the fallback in phpunit.xml) so your suite never hits ip-api.com, and seed a small fixture subset rather than the full world dump in test databases.How can I help you explore Laravel packages today?