added countries support expanded for ISO 3166 standard

This commit is contained in:
Mark Gregory
2026-06-15 09:27:57 +01:00
parent 76521e07fe
commit 3638e34535
21 changed files with 771 additions and 50 deletions
@@ -0,0 +1,85 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add ISO 3166 governance metadata and ISO 3166-2 subdivision support.
*/
public function up(): void
{
Schema::table('countries', function (Blueprint $table): void {
if (! Schema::hasColumn('countries', 'iso_status')) {
$table->string('iso_status', 32)->default('official')->index()->after('is_active')->comment('ISO 3166-1 lifecycle status: official, reserved, transitional, deleted, user_assigned, exceptionally_reserved.');
}
if (! Schema::hasColumn('countries', 'independent')) {
$table->boolean('independent')->nullable()->after('iso_status')->comment('Whether the ISO 3166-1 entry represents an independent state where known.');
}
if (! Schema::hasColumn('countries', 'region')) {
$table->string('region')->nullable()->index()->after('independent')->comment('UN M49-style broad region for filtering and reporting.');
}
if (! Schema::hasColumn('countries', 'subregion')) {
$table->string('subregion')->nullable()->index()->after('region')->comment('UN M49-style subregion for filtering and reporting.');
}
if (! Schema::hasColumn('countries', 'source')) {
$table->string('source')->nullable()->after('last_synced_at')->comment('Reference data source used for the last sync.');
}
if (! Schema::hasColumn('countries', 'source_version')) {
$table->string('source_version')->nullable()->after('source')->comment('Reference data version/release identifier.');
}
if (! Schema::hasColumn('countries', 'metadata')) {
$table->json('metadata')->nullable()->after('source_version')->comment('Additional non-authoritative metadata, never used as the primary ISO identity.');
}
});
if (! Schema::hasTable('country_subdivisions')) {
Schema::create('country_subdivisions', function (Blueprint $table): void {
$table->id();
$table->foreignId('country_id')->constrained('countries')->cascadeOnDelete();
$table->char('country_alpha2', 2)->index()->comment('Parent ISO 3166-1 alpha-2 code, duplicated for fast lookup and imports.');
$table->string('code', 16)->unique()->comment('Full ISO 3166-2 subdivision code, for example GB-ENG or US-CA.');
$table->string('subdivision_code', 12)->comment('Subdivision part after the country prefix, for example ENG or CA.');
$table->string('name')->comment('ISO 3166-2 subdivision name.');
$table->string('local_name')->nullable()->comment('Optional local-language display name.');
$table->string('type')->nullable()->index()->comment('Subdivision type, for example country, province, state, territory, council area.');
$table->boolean('is_active')->default(true)->index()->comment('Whether this subdivision is currently available in business workflows.');
$table->string('iso_status', 32)->default('official')->index()->comment('ISO 3166-2 lifecycle status.');
$table->unsignedInteger('sort_order')->default(0)->index();
$table->timestamp('last_synced_at')->nullable();
$table->string('source')->nullable();
$table->string('source_version')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['country_id', 'subdivision_code']);
$table->index(['country_alpha2', 'is_active', 'sort_order', 'name']);
$table->index(['type', 'is_active']);
});
}
}
/**
* Reverse the ISO 3166 support additions.
*/
public function down(): void
{
Schema::dropIfExists('country_subdivisions');
Schema::table('countries', function (Blueprint $table): void {
foreach (['metadata', 'source_version', 'source', 'subregion', 'region', 'independent', 'iso_status'] as $column) {
if (Schema::hasColumn('countries', $column)) {
$table->dropColumn($column);
}
}
});
}
};
@@ -6,8 +6,14 @@ use Illuminate\Database\Seeder;
class CountriesDatabaseSeeder extends Seeder
{
/**
* Seed ISO 3166 country reference data.
*/
public function run(): void
{
$this->call(IsoCountriesSeeder::class);
$this->call([
IsoCountriesSeeder::class,
IsoCountrySubdivisionsSeeder::class,
]);
}
}
@@ -270,6 +270,9 @@ class IsoCountriesSeeder extends Seeder
'is_active' => true,
'sort_order' => $index + 1,
'last_synced_at' => $now,
'iso_status' => 'official',
'source' => 'bundled-iso-3166-1',
'source_version' => '2026-06-15',
])
);
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Countries\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Modules\Countries\Models\Country;
use Modules\Countries\Models\CountrySubdivision;
class IsoCountrySubdivisionsSeeder extends Seeder
{
/**
* Seed high-value ISO 3166-2 subdivisions used by UK/US business workflows.
*
* This module is structured to import the full ISO 3166-2 data set from an approved licensed source.
* The bundled seed keeps the module useful out of the box without pretending to be the complete ISO 3166-2 catalogue.
*/
public function run(): void
{
$now = Carbon::now();
$rows = [
['country' => 'GB', 'code' => 'GB-ENG', 'name' => 'England', 'type' => 'country'],
['country' => 'GB', 'code' => 'GB-SCT', 'name' => 'Scotland', 'type' => 'country'],
['country' => 'GB', 'code' => 'GB-WLS', 'name' => 'Wales', 'type' => 'country'],
['country' => 'GB', 'code' => 'GB-NIR', 'name' => 'Northern Ireland', 'type' => 'province'],
['country' => 'US', 'code' => 'US-AL', 'name' => 'Alabama', 'type' => 'state'],
['country' => 'US', 'code' => 'US-AK', 'name' => 'Alaska', 'type' => 'state'],
['country' => 'US', 'code' => 'US-AZ', 'name' => 'Arizona', 'type' => 'state'],
['country' => 'US', 'code' => 'US-AR', 'name' => 'Arkansas', 'type' => 'state'],
['country' => 'US', 'code' => 'US-CA', 'name' => 'California', 'type' => 'state'],
['country' => 'US', 'code' => 'US-CO', 'name' => 'Colorado', 'type' => 'state'],
['country' => 'US', 'code' => 'US-CT', 'name' => 'Connecticut', 'type' => 'state'],
['country' => 'US', 'code' => 'US-DE', 'name' => 'Delaware', 'type' => 'state'],
['country' => 'US', 'code' => 'US-FL', 'name' => 'Florida', 'type' => 'state'],
['country' => 'US', 'code' => 'US-NY', 'name' => 'New York', 'type' => 'state'],
['country' => 'US', 'code' => 'US-TX', 'name' => 'Texas', 'type' => 'state'],
['country' => 'US', 'code' => 'US-WA', 'name' => 'Washington', 'type' => 'state'],
['country' => 'CA', 'code' => 'CA-AB', 'name' => 'Alberta', 'type' => 'province'],
['country' => 'CA', 'code' => 'CA-BC', 'name' => 'British Columbia', 'type' => 'province'],
['country' => 'CA', 'code' => 'CA-ON', 'name' => 'Ontario', 'type' => 'province'],
['country' => 'CA', 'code' => 'CA-QC', 'name' => 'Quebec', 'type' => 'province'],
];
foreach ($rows as $index => $row) {
$country = Country::query()->where('alpha2', $row['country'])->first();
if (! $country) {
continue;
}
CountrySubdivision::query()->updateOrCreate(
['code' => $row['code']],
[
'country_id' => $country->id,
'country_alpha2' => $country->alpha2,
'subdivision_code' => substr($row['code'], 3),
'name' => $row['name'],
'local_name' => null,
'type' => $row['type'],
'is_active' => true,
'iso_status' => 'official',
'sort_order' => $index + 1,
'last_synced_at' => $now,
'source' => 'bundled-bootstrap',
'source_version' => '2026-06-15',
]
);
}
}
}
@@ -6,26 +6,87 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Countries\Models\Country;
use Modules\Countries\Models\CountrySubdivision;
use Modules\Countries\Support\CountryRepository;
class CountriesApiController extends Controller
{
/**
* List ISO 3166-1 countries for integrations and selectors.
*/
public function index(Request $request): JsonResponse
{
$activeOnly = $request->boolean('active_only', false);
$countries = Country::query()->when($activeOnly, fn ($query) => $query->where('is_active', true))->ordered()
->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active']);
$search = trim((string) $request->query('search', ''));
$countries = Country::query()
->when($activeOnly, fn ($query) => $query->where('is_active', true))
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($nested) use ($search): void {
$nested->where('name', 'ilike', "%{$search}%")
->orWhere('official_name', 'ilike', "%{$search}%")
->orWhere('common_name', 'ilike', "%{$search}%")
->orWhere('display_name', 'ilike', "%{$search}%")
->orWhere('alpha2', 'ilike', "%{$search}%")
->orWhere('alpha3', 'ilike', "%{$search}%")
->orWhere('numeric', 'ilike', "%{$search}%")
->orWhere('region', 'ilike', "%{$search}%")
->orWhere('subregion', 'ilike', "%{$search}%");
});
})
->ordered()
->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion']);
return response()->json(['data' => $countries]);
}
/**
* List active ISO 3166-1 countries for business selectors.
*/
public function active(): JsonResponse
{
$countries = Country::query()->active()->ordered()
->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji']);
->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']);
return response()->json(['data' => $countries]);
}
/**
* Show one country by alpha-2 route binding.
*/
public function show(Country $country): JsonResponse
{
return response()->json(['data' => $country->loadCount('subdivisions')]);
}
/**
* Resolve alpha-2, alpha-3 or numeric ISO 3166-1 country code.
*/
public function resolve(string $code, CountryRepository $countries): JsonResponse
{
$country = $countries->findByIsoCode($code);
abort_if($country === null, 404, 'Country code not found.');
return response()->json(['data' => $country]);
}
/**
* List ISO 3166-2 subdivisions for the supplied ISO 3166-1 alpha-2 country.
*/
public function subdivisions(string $alpha2, Request $request, CountryRepository $countries): JsonResponse
{
$activeOnly = $request->boolean('active_only', true);
$country = $countries->findByAlpha2($alpha2);
abort_if($country === null, 404, 'Country not found.');
$subdivisions = CountrySubdivision::query()
->where('country_id', $country->id)
->when($activeOnly, fn ($query) => $query->where('is_active', true))
->ordered()
->get(['country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type', 'is_active', 'iso_status']);
return response()->json(['data' => $subdivisions]);
}
}
@@ -57,6 +57,10 @@ class CountriesController extends Controller
'display_name' => $request->validated('display_name'),
'is_active' => $request->boolean('is_active'),
'sort_order' => (int) ($request->validated('sort_order') ?? 0),
'iso_status' => $request->validated('iso_status'),
'independent' => $request->boolean('independent'),
'region' => $request->validated('region'),
'subregion' => $request->validated('subregion'),
]);
return redirect()->route('countries.show', $country)->with('success', 'Country settings updated.');
@@ -3,20 +3,32 @@
namespace Modules\Countries\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Countries\Support\Iso3166;
class UpdateCountryRequest extends FormRequest
{
/**
* Authorize country reference-data administration.
*/
public function authorize(): bool
{
return (bool) $this->user();
return (bool) $this->user()?->isMpbaPlatformAdmin();
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'display_name' => ['nullable', 'string', 'max:255'],
'is_active' => ['nullable', 'boolean'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())],
'independent' => ['nullable', 'boolean'],
'region' => ['nullable', 'string', 'max:100'],
'subregion' => ['nullable', 'string', 'max:100'],
];
}
}
+29 -1
View File
@@ -5,6 +5,7 @@ namespace Modules\Countries\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Country extends Model
{
@@ -12,13 +13,16 @@ class Country extends Model
protected $fillable = [
'alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name',
'display_name', 'flag_emoji', 'is_active', 'sort_order', 'last_synced_at',
'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion',
'sort_order', 'last_synced_at', 'source', 'source_version', 'metadata',
];
protected $casts = [
'is_active' => 'boolean',
'sort_order' => 'integer',
'last_synced_at' => 'datetime',
'independent' => 'boolean',
'metadata' => 'array',
];
public function getRouteKeyName(): string
@@ -31,6 +35,30 @@ class Country extends Model
return $this->display_name ?: $this->common_name ?: $this->name;
}
/**
* ISO 3166-2 subdivisions belonging to this country.
*/
public function subdivisions(): HasMany
{
return $this->hasMany(CountrySubdivision::class);
}
/**
* Resolve a country from alpha-2, alpha-3 or numeric ISO 3166-1 code.
*/
public function scopeForIsoCode(Builder $query, string $code): Builder
{
$normalised = strtoupper(trim($code));
return $query->where(function (Builder $nested) use ($normalised): void {
$nested->where('alpha2', $normalised)
->orWhere('alpha3', $normalised)
->orWhere('numeric', str_pad($normalised, 3, '0', STR_PAD_LEFT));
});
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
@@ -0,0 +1,71 @@
<?php
namespace Modules\Countries\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CountrySubdivision extends Model
{
use HasFactory;
/** @var list<string> */
protected $fillable = [
'country_id',
'country_alpha2',
'code',
'subdivision_code',
'name',
'local_name',
'type',
'is_active',
'iso_status',
'sort_order',
'last_synced_at',
'source',
'source_version',
'metadata',
];
/** @var array<string, string> */
protected $casts = [
'is_active' => 'boolean',
'sort_order' => 'integer',
'last_synced_at' => 'datetime',
'metadata' => 'array',
];
/**
* Use the ISO 3166-2 code for route model binding.
*/
public function getRouteKeyName(): string
{
return 'code';
}
/**
* Parent ISO 3166-1 country.
*/
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
/**
* Scope active subdivisions.
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* Scope ordered subdivisions for selectors and administration screens.
*/
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('name');
}
}
@@ -1,9 +1,69 @@
<x-layouts.app>
<div class="mx-auto max-w-[1000px] space-y-6" dusk="countries-edit">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"><p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Country support</p><h1 class="mt-2 text-3xl font-semibold tracking-tight">Edit {{ $country->flag_emoji }} {{ $country->effective_name }}</h1><p class="mt-2 max-w-3xl text-sm text-blue-100/90">ISO codes are locked. Only support status, display name and sort order can be changed.</p></div></div>
<form method="POST" action="{{ route('countries.update', $country) }}" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">@csrf @method('PUT')
<div class="grid gap-5"><div><label class="text-sm font-semibold" for="display_name">Display name override</label><input id="display_name" name="display_name" value="{{ old('display_name', $country->display_name) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />@error('display_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror</div><div><label class="text-sm font-semibold" for="sort_order">Sort order</label><input id="sort_order" name="sort_order" type="number" min="0" value="{{ old('sort_order', $country->sort_order) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />@error('sort_order')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror</div><label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950"><input type="hidden" name="is_active" value="0" /><input type="checkbox" name="is_active" value="1" @checked(old('is_active', $country->is_active)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" /><span><span class="block text-sm font-semibold">Active country</span><span class="block text-xs text-zinc-500">Inactive countries remain in the ISO table but are hidden from normal selection lists.</span></span></label></div>
<div class="mt-6 flex flex-wrap gap-2"><button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Save country</button><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-xl bg-zinc-100 px-4 py-2.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700">Cancel</a></div>
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country support</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Edit {{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">ISO identity codes are locked. Platform administrators may control support status, display naming and operational metadata.</p>
</div>
</div>
<form method="POST" action="{{ route('countries.update', $country) }}" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
@csrf
@method('PUT')
<div class="grid gap-5 md:grid-cols-2">
<div class="md:col-span-2">
<label class="text-sm font-semibold" for="display_name">Display name override</label>
<input id="display_name" name="display_name" value="{{ old('display_name', $country->display_name) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
@error('display_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-semibold" for="sort_order">Sort order</label>
<input id="sort_order" name="sort_order" type="number" min="0" value="{{ old('sort_order', $country->sort_order) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
@error('sort_order')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-semibold" for="iso_status">ISO lifecycle status</label>
<select id="iso_status" name="iso_status" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
@foreach (\Modules\Countries\Support\Iso3166::statuses() as $status)
<option value="{{ $status }}" @selected(old('iso_status', $country->iso_status ?? 'official') === $status)>{{ str($status)->replace('_', ' ')->title() }}</option>
@endforeach
</select>
@error('iso_status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-semibold" for="region">Region</label>
<input id="region" name="region" value="{{ old('region', $country->region) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
@error('region')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-semibold" for="subregion">Subregion</label>
<input id="subregion" name="subregion" value="{{ old('subregion', $country->subregion) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
@error('subregion')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
<input type="hidden" name="is_active" value="0" />
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $country->is_active)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
<span><span class="block text-sm font-semibold">Supported in business workflows</span><span class="block text-xs text-zinc-500">Unsupported countries stay in the ISO table but are hidden from normal selectors.</span></span>
</label>
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
<input type="hidden" name="independent" value="0" />
<input type="checkbox" name="independent" value="1" @checked(old('independent', $country->independent)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
<span><span class="block text-sm font-semibold">Independent state</span><span class="block text-xs text-zinc-500">Optional reference flag for reporting and compliance workflows.</span></span>
</label>
</div>
<div class="mt-6 flex flex-wrap gap-2">
<button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Save country</button>
<a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-xl bg-zinc-100 px-4 py-2.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700">Cancel</a>
</div>
</form>
</div>
</x-layouts.app>
@@ -18,8 +18,8 @@
<select name="status" class="min-h-11 rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"><option value="all" @selected($status === 'all')>All</option><option value="active" @selected($status === 'active')>Active</option><option value="inactive" @selected($status === 'inactive')>Inactive</option></select>
<button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Filter</button>
</form>
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800"><thead class="bg-zinc-50 dark:bg-zinc-950"><tr><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Country</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-2</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-3</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Numeric</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Status</th><th class="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-zinc-500">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($countries as $country)<tr class="hover:bg-blue-50/50 dark:hover:bg-blue-950/20"><td class="px-4 py-3"><div class="flex items-center gap-3"><span class="text-xl">{{ $country->flag_emoji }}</span><div><div class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $country->effective_name }}</div><div class="text-xs text-zinc-500">{{ $country->official_name ?: $country->name }}</div></div></div></td><td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $country->alpha2 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->alpha3 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->numeric }}</td><td class="px-4 py-3">@if ($country->is_active)<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-bold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Active</span>@else<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950 dark:text-amber-200">Inactive</span>@endif</td><td class="px-4 py-3 text-right"><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a> <a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Edit</a></td></tr>@empty<tr><td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-500">No countries matched your search.</td></tr>@endforelse
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800"><thead class="bg-zinc-50 dark:bg-zinc-950"><tr><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Country</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-2</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-3</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Numeric</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">ISO status</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Status</th><th class="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-zinc-500">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($countries as $country)<tr class="hover:bg-blue-50/50 dark:hover:bg-blue-950/20"><td class="px-4 py-3"><div class="flex items-center gap-3"><span class="text-xl">{{ $country->flag_emoji }}</span><div><div class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $country->effective_name }}</div><div class="text-xs text-zinc-500">{{ $country->official_name ?: $country->name }}</div></div></div></td><td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $country->alpha2 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->alpha3 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->numeric }}</td><td class="px-4 py-3 text-sm text-zinc-500">{{ str($country->iso_status ?? 'official')->replace('_', ' ')->title() }}</td><td class="px-4 py-3">@if ($country->is_active)<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-bold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Active</span>@else<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950 dark:text-amber-200">Inactive</span>@endif</td><td class="px-4 py-3 text-right"><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a> <a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Edit</a></td></tr>@empty<tr><td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-500">No countries matched your search.</td></tr>@endforelse
</tbody></table></div><div class="border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">{{ $countries->links() }}</div></div>
</div>
</x-layouts.app>
@@ -1,7 +1,72 @@
<x-layouts.app>
<div class="mx-auto max-w-[1200px] space-y-6" dusk="countries-show">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"><p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO country</p><h1 class="mt-2 text-3xl font-semibold tracking-tight">{{ $country->flag_emoji }} {{ $country->effective_name }}</h1><p class="mt-2 max-w-3xl text-sm text-blue-100/90">{{ $country->official_name ?: $country->name }}</p><div class="mt-4 flex gap-2"><a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700">Edit support</a><a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back</a></div></div></div>
@if (session('success'))<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('success') }}</div>@endif
<div class="grid gap-4 md:grid-cols-2">@foreach (['Alpha-2' => $country->alpha2, 'Alpha-3' => $country->alpha3, 'Numeric' => $country->numeric, 'Canonical name' => $country->name, 'Official name' => $country->official_name ?: '—', 'Common name' => $country->common_name ?: '—', 'Display override' => $country->display_name ?: '—', 'Active' => $country->is_active ? 'Yes' : 'No'] as $label => $value)<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="text-xs font-bold uppercase tracking-wide text-zinc-500">{{ $label }}</div><div class="mt-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ $value }}</div></div>@endforeach</div>
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">{{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">{{ $country->official_name ?: $country->name }}</p>
<div class="mt-4 flex gap-2">
<a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700">Edit support</a>
<a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back</a>
</div>
</div>
</div>
@if (session('success'))
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('success') }}</div>
@endif
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@foreach ([
'Alpha-2' => $country->alpha2,
'Alpha-3' => $country->alpha3,
'Numeric' => $country->numeric,
'Canonical name' => $country->name,
'Official name' => $country->official_name ?: '—',
'Common name' => $country->common_name ?: '—',
'Display override' => $country->display_name ?: '—',
'ISO status' => str($country->iso_status ?? 'official')->replace('_', ' ')->title(),
'Region' => $country->region ?: '—',
'Subregion' => $country->subregion ?: '—',
'Independent' => is_null($country->independent) ? '—' : ($country->independent ? 'Yes' : 'No'),
'Active' => $country->is_active ? 'Yes' : 'No',
] as $label => $value)
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-xs font-bold uppercase tracking-wide text-zinc-500">{{ $label }}</div>
<div class="mt-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ $value }}</div>
</div>
@endforeach
</div>
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">ISO 3166-2 subdivisions</h2>
<p class="text-xs text-zinc-500">Subdivisions are stored separately for address, compliance and shipping workflows.</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800">
<thead class="bg-zinc-50 dark:bg-zinc-950">
<tr>
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Code</th>
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Name</th>
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Type</th>
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($country->subdivisions()->ordered()->limit(100)->get() as $subdivision)
<tr>
<td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $subdivision->code }}</td>
<td class="px-4 py-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $subdivision->name }}</td>
<td class="px-4 py-3 text-sm text-zinc-500">{{ $subdivision->type ?: '—' }}</td>
<td class="px-4 py-3 text-sm text-zinc-500">{{ str($subdivision->iso_status)->replace('_', ' ')->title() }}</td>
</tr>
@empty
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-zinc-500">No subdivisions have been imported for this country yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-layouts.app>
+3 -1
View File
@@ -3,8 +3,10 @@
use Illuminate\Support\Facades\Route;
use Modules\Countries\Http\Controllers\Api\CountriesApiController;
Route::prefix('countries')->name('api.countries.')->group(function (): void {
Route::middleware(['api'])->prefix('api/countries')->name('api.countries.')->group(function (): void {
Route::get('/', [CountriesApiController::class, 'index'])->name('index');
Route::get('/active', [CountriesApiController::class, 'active'])->name('active');
Route::get('/resolve/{code}', [CountriesApiController::class, 'resolve'])->where('code', '[A-Za-z0-9]{2,3}')->name('resolve');
Route::get('/{country:alpha2}', [CountriesApiController::class, 'show'])->name('show');
Route::get('/{alpha2}/subdivisions', [CountriesApiController::class, 'subdivisions'])->where('alpha2', '[A-Za-z]{2}')->name('subdivisions');
});
+1 -15
View File
@@ -4,7 +4,6 @@ namespace Modules\Countries\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Countries\Models\Country;
class ValidCountryCode implements ValidationRule
{
@@ -14,19 +13,6 @@ class ValidCountryCode implements ValidationRule
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || strlen($value) !== 2) {
$fail('The :attribute must be a valid ISO 3166 alpha-2 country code.');
return;
}
$query = Country::query()->where('alpha2', strtoupper($value));
if ($this->activeOnly) {
$query->where('is_active', true);
}
if (! $query->exists()) {
$fail('The :attribute must be a supported country.');
}
(new ValidIso3166CountryCode($this->activeOnly, 'alpha2'))->validate($attribute, $value, $fail);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Countries\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Countries\Models\Country;
class ValidIso3166CountryCode implements ValidationRule
{
public function __construct(
private readonly bool $activeOnly = true,
private readonly string $format = 'alpha2',
) {
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) && ! is_int($value)) {
$fail('The :attribute must be a valid ISO 3166 country code.');
return;
}
$code = strtoupper(trim((string) $value));
$query = Country::query();
match ($this->format) {
'alpha2' => $query->where('alpha2', $code),
'alpha3' => $query->where('alpha3', $code),
'numeric' => $query->where('numeric', str_pad($code, 3, '0', STR_PAD_LEFT)),
'any' => $query->forIsoCode($code),
default => $query->whereRaw('1 = 0'),
};
if ($this->activeOnly) {
$query->where('is_active', true);
}
if (! $query->exists()) {
$fail('The :attribute must be a supported ISO 3166 country code.');
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Countries\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Countries\Models\CountrySubdivision;
class ValidIso3166SubdivisionCode implements ValidationRule
{
public function __construct(private readonly bool $activeOnly = true)
{
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! preg_match('/^[A-Z]{2}-[A-Z0-9]{1,6}$/', strtoupper($value))) {
$fail('The :attribute must be a valid ISO 3166-2 subdivision code.');
return;
}
$query = CountrySubdivision::query()->where('code', strtoupper($value));
if ($this->activeOnly) {
$query->where('is_active', true);
}
if (! $query->exists()) {
$fail('The :attribute must be a supported ISO 3166-2 subdivision code.');
}
}
}
@@ -4,17 +4,49 @@ namespace Modules\Countries\Support;
use Illuminate\Support\Collection;
use Modules\Countries\Models\Country;
use Modules\Countries\Models\CountrySubdivision;
class CountryRepository
{
/**
* Active countries for form selects.
*
* @return Collection<int, Country>
*/
public function activeForSelect(): Collection
{
return Country::query()->active()->ordered()
->get(['id', 'alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji']);
->get(['id', 'alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']);
}
public function findByAlpha2(string $alpha2): ?Country
{
return Country::query()->where('alpha2', strtoupper($alpha2))->first();
return Country::query()->where('alpha2', Iso3166::normaliseAlpha2($alpha2))->first();
}
public function findByIsoCode(string $code): ?Country
{
return Country::query()->forIsoCode($code)->first();
}
public function findSubdivision(string $code): ?CountrySubdivision
{
return CountrySubdivision::query()->where('code', strtoupper(trim($code)))->first();
}
/**
* Active subdivisions for a country select.
*
* @return Collection<int, CountrySubdivision>
*/
public function activeSubdivisionsForCountry(string $alpha2): Collection
{
$alpha2 = Iso3166::normaliseAlpha2($alpha2);
return CountrySubdivision::query()
->where('country_alpha2', $alpha2)
->active()
->ordered()
->get(['id', 'country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type']);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Modules\Countries\Support;
use InvalidArgumentException;
final class Iso3166
{
public const STATUS_OFFICIAL = 'official';
public const STATUS_RESERVED = 'reserved';
public const STATUS_TRANSITIONAL = 'transitional';
public const STATUS_DELETED = 'deleted';
public const STATUS_USER_ASSIGNED = 'user_assigned';
public const STATUS_EXCEPTIONALLY_RESERVED = 'exceptionally_reserved';
/** @return list<string> */
public static function statuses(): array
{
return [
self::STATUS_OFFICIAL,
self::STATUS_RESERVED,
self::STATUS_TRANSITIONAL,
self::STATUS_DELETED,
self::STATUS_USER_ASSIGNED,
self::STATUS_EXCEPTIONALLY_RESERVED,
];
}
public static function normaliseAlpha2(string $code): string
{
$normalised = strtoupper(trim($code));
if (! preg_match('/^[A-Z]{2}$/', $normalised)) {
throw new InvalidArgumentException('ISO 3166-1 alpha-2 codes must contain exactly two letters.');
}
return $normalised;
}
public static function normaliseAlpha3(string $code): string
{
$normalised = strtoupper(trim($code));
if (! preg_match('/^[A-Z]{3}$/', $normalised)) {
throw new InvalidArgumentException('ISO 3166-1 alpha-3 codes must contain exactly three letters.');
}
return $normalised;
}
public static function normaliseNumeric(string|int $code): string
{
$normalised = str_pad((string) $code, 3, '0', STR_PAD_LEFT);
if (! preg_match('/^[0-9]{3}$/', $normalised)) {
throw new InvalidArgumentException('ISO 3166-1 numeric codes must contain exactly three digits.');
}
return $normalised;
}
public static function normaliseSubdivisionCode(string $countryAlpha2, string $subdivisionCode): string
{
$country = self::normaliseAlpha2($countryAlpha2);
$code = strtoupper(trim($subdivisionCode));
if (str_starts_with($code, $country.'-')) {
return $code;
}
if (! preg_match('/^[A-Z0-9]{1,6}$/', $code)) {
throw new InvalidArgumentException('ISO 3166-2 subdivision codes must contain 1 to 6 letters or numbers after the country prefix.');
}
return $country.'-'.$code;
}
public static function flagEmoji(string $alpha2): string
{
$alpha2 = self::normaliseAlpha2($alpha2);
$base = 127397;
return mb_chr($base + ord($alpha2[0]), 'UTF-8').mb_chr($base + ord($alpha2[1]), 'UTF-8');
}
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\Countries\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Countries\Database\Seeders\CountriesDatabaseSeeder;
use Modules\Countries\Models\Country;
use Modules\Countries\Models\CountrySubdivision;
use Modules\Countries\Rules\ValidIso3166CountryCode;
use Modules\Countries\Rules\ValidIso3166SubdivisionCode;
use Tests\TestCase;
class Iso3166SupportTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_iso_3166_country_and_subdivision_reference_data(): void
{
$this->seed(CountriesDatabaseSeeder::class);
$this->assertDatabaseHas('countries', [
'alpha2' => 'GB',
'alpha3' => 'GBR',
'numeric' => '826',
'iso_status' => 'official',
]);
$this->assertDatabaseHas('country_subdivisions', [
'country_alpha2' => 'GB',
'code' => 'GB-ENG',
'name' => 'England',
]);
}
public function test_it_resolves_country_by_any_iso_3166_1_code(): void
{
$this->seed(CountriesDatabaseSeeder::class);
$this->getJson('/api/countries/resolve/GBR')
->assertOk()
->assertJsonPath('data.alpha2', 'GB');
$this->getJson('/api/countries/resolve/826')
->assertOk()
->assertJsonPath('data.alpha3', 'GBR');
}
public function test_it_lists_iso_3166_2_subdivisions_for_country(): void
{
$this->seed(CountriesDatabaseSeeder::class);
$this->getJson('/api/countries/GB/subdivisions')
->assertOk()
->assertJsonFragment(['code' => 'GB-ENG'])
->assertJsonFragment(['code' => 'GB-SCT']);
}
public function test_validation_rules_support_country_and_subdivision_codes(): void
{
$this->seed(CountriesDatabaseSeeder::class);
$countryRule = new ValidIso3166CountryCode(activeOnly: true, format: 'any');
$subdivisionRule = new ValidIso3166SubdivisionCode(activeOnly: true);
$countryFailed = false;
$subdivisionFailed = false;
$countryRule->validate('country', 'GBR', function () use (&$countryFailed): void {
$countryFailed = true;
});
$subdivisionRule->validate('subdivision', 'GB-ENG', function () use (&$subdivisionFailed): void {
$subdivisionFailed = true;
});
$this->assertFalse($countryFailed);
$this->assertFalse($subdivisionFailed);
}
}
+13 -16
View File
@@ -1,22 +1,19 @@
1.2.1:
date: 2026-06-15
summary: "Restored Countries module route names to countries.* to match the registered application route and prevent RouteNotFoundException."
module: Countries
current_version: 1.2.0
entries:
versions:
- version: 1.2.0
date: 2026-06-15
type: change
summary: "Added Countries to the WMS Platform Administration sidebar for MPBA platform administrators using the existing route guard, active-state class, and sidebar styling."
changes:
- Added ISO 3166-1 lifecycle metadata fields to countries.
- Added ISO 3166-2 country subdivision table and model.
- Added ISO 3166 helper for alpha-2, alpha-3, numeric and subdivision code normalisation.
- Added API endpoints for resolving country codes and listing subdivisions.
- Added validation rules for ISO 3166-1 and ISO 3166-2 codes.
- Added bootstrap subdivision seed data for GB, US and CA.
- Updated administration views for ISO status, region, subregion and subdivision visibility.
- version: 1.1.0
date: 2026-06-15
type: feature
summary: "Completed ISO 3166 countries reference module with seeded country data, professional WMS UI, admin activation controls, API lookup endpoints, validation rule, and tests."
changes:
- Added ISO country reference administration support.
- version: 1.0.0
date: 2026-06-15
type: initial
summary: "Initial module skeleton."
- version: 1.1.0
date: 2026-06-15
summary: "Moved Countries to Platform Administration using platform.countries.* route names under /platform/countries."
changes:
- Initial Countries module skeleton.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Countries",
"alias": "countries",
"version": "1.2.1",
"version": "1.2.0",
"description": "ISO 3166 country reference module with active-country support for WMS and SaaS workflows.",
"keywords": [
"countries",