Content Accord is a Laravel package for API versioning with composable strategies and a generic negotiation layer. It supports URI, header, and Accept header versioning behind a single fluent API and prepares your application for future negotiation dimensions (locale, format, tenant).
- URI, custom header, or Accept header versioning
- Optional resolver chaining (try multiple strategies in order)
- Configurable missing-version behavior
- Per-route-group fallback when the requested version is missing
- Deprecation headers with sunset and documentation links
- Attribute-driven version metadata on controllers and methods
- Generic negotiation foundation for future dimensions
- PHP 8.3+
- Laravel 12+ (Laravel 11 and 13 are supported via constraints)
composer require gaiatools/content-accordPublish the configuration file:
php artisan vendor:publish --tag=content-accord-configThe main configuration lives in config/content-accord.php under the versioning key.
Key settings:
dimensions: array of dimension services to negotiatestrategy:uri,header, oracceptresolver: custom resolver class/binding for versioningchain: array of strategies to try in ordermissing_strategy:reject,default,latest, orrequiredefault_version: used when missing strategy isdefaultfallback: global default for version fallbackversions: registered versions and deprecation metadata
Use Route::apiVersion() to declare versioned route groups. The URI prefix is
managed automatically based on your configured resolver strategy.
use Illuminate\Support\Facades\Route;
Route::apiVersion('1')
->prefix('api')
->middleware(['content-accord.negotiate'])
->group(function () {
Route::get('/users', [V1\UserController::class, 'index']);
});
Route::apiVersion('2')
->prefix('api')
->middleware(['content-accord.negotiate'])
->group(function () {
Route::get('/users', [V2\UserController::class, 'index']);
});With the URI strategy (default), the above registers at /api/v1/users and
/api/v2/users. With header or Accept strategies, both register at /api/users
and Content Accord selects the right route at dispatch time.
Deprecation metadata is a fluent chain:
Route::apiVersion('1')
->prefix('api')
->deprecated()
->sunsetDate('2026-03-01')
->deprecationLink('https://docs.example.com/v1-migration')
->middleware(['content-accord.negotiate'])
->group(function () {
Route::get('/users', [V1\UserController::class, 'index']);
});// config/content-accord.php
'versioning' => ['strategy' => 'header'],Route::apiVersion('1')
->prefix('api')
->middleware(['content-accord.negotiate'])
->group(function () {
Route::get('/users', [V1\UserController::class, 'index']);
});Requests:
GET /api/users
Api-Version: 1GET /api/users
Accept: application/vnd.myapp.v1+jsonOverride the negotiated dimensions or the resolver implementation:
use GaiaTools\ContentAccord\Dimensions\VersioningDimension;
use App\Http\Negotiation\LocaleDimension;
'dimensions' => [
VersioningDimension::class,
LocaleDimension::class,
],
'versioning' => [
'resolver' => [
App\Http\Negotiation\CustomVersionResolver::class,
GaiaTools\ContentAccord\Resolvers\Version\HeaderVersionResolver::class,
],
],Register any custom dimensions/resolvers in the container so they can be resolved.
Configure what happens when a request has no version:
'missing_strategy' => 'default',
'default_version' => '1',Enable fallback globally or per group:
// config
'fallback' => false,
// route group override
Route::apiVersion('2')
->prefix('api')
->fallback()
->middleware(['content-accord.negotiate'])
->group(function () {
Route::get('/users', [V2\UserController::class, 'index']);
});If a request targets v3 but only v2 exists for that endpoint, the v2 route will be selected when fallback is enabled.
Add version metadata on controllers or methods:
use GaiaTools\ContentAccord\Attributes\ApiVersion;
use GaiaTools\ContentAccord\Attributes\MapToVersion;
#[ApiVersion('2')]
class UserController
{
public function index() {}
#[MapToVersion('2.1')]
public function show() {}
}Method-level attributes take precedence over class-level attributes. Attribute versions override the group version in route metadata. Mismatches are logged in local/testing environments.
Mark version groups as deprecated and optionally add sunset dates and docs links:
Route::apiVersion('1')
->prefix('api')
->deprecated()
->sunsetDate('2026-03-01')
->deprecationLink('https://docs.example.com/v1-migration')
->middleware(['content-accord.deprecate', 'content-accord.negotiate'])
->group(function () {
Route::get('/users', [V1\UserController::class, 'index']);
});The Deprecation, Sunset, and Link headers are added automatically when deprecation metadata is present.
Use the apiVersion() helper in controllers or anywhere after the negotiate
middleware has run:
use GaiaTools\ContentAccord\ValueObjects\ApiVersion;
public function index(): JsonResponse
{
$version = apiVersion(); // ?ApiVersion
}Or inject NegotiatedContext directly:
use GaiaTools\ContentAccord\Http\NegotiatedContext;
$version = app(NegotiatedContext::class)->get('version');Use the testing helper to attach API versions to test requests:
use GaiaTools\ContentAccord\Testing\Concerns\InteractsWithApiVersion;
class ExampleTest extends TestCase
{
use InteractsWithApiVersion;
public function test_example()
{
$this->withApiVersion('2')->get('/api/users');
}
}The helper respects the configured strategy (URI, header, or Accept).
List configured versions and route counts:
php artisan api:versionsMIT