When integrating API Platform with Wayfinder + Inertia/Vue in a SPA architecture, several issues arise due to how API Platform generates routes.
1️⃣ Routes contain {_format?} segments
API Platform generates routes like:
/api/bots/{id}{_format?}
The {_format?} parameter is added to support multiple output formats (json, jsonld, etc.). However, this causes problems with Wayfinder, which includes the {_format?} part in generated JavaScript route definitions, producing messy names such as:
api/api/bots/{id}{._format}_get api/api/bots/{id}{._format}_patch
This results in ugly, hard-to-maintain route helpers in the front-end.
2️⃣ Poor compatibility with SPA routing
In a SPA (Inertia + Vue), the frontend expects clean and predictable routes, such as:
/api/bots /api/bots/:id
The {_format?} parameter complicates Wayfinder's behavior, navigation, and code generation.
3️⃣ _format remains even with a single format enabled
Even when the configuration specifies only one format:
'formats' => [ 'jsonld' => ['application/ld+json'], ],
API Platform still generates routes with the {_format?} placeholder. This happens because of how API Platform builds route templates internally — the format parameter is always optional, even when only one format is used.
4️⃣ Negative consequences
Wayfinder generates noisy and duplicated frontend route definitions.
CRUD operations become harder to work with in the JS layer.
API consumption from SPA code becomes less clean and harder to maintain.
Goal
The goal is to remove {_format?} from API Platform-generated routes, while keeping the API functional with a single output format (JSON).
This would produce clean and predictable routes:
/api/bots /api/bots/{id}
And Wayfinder would generate much more maintainable route helpers for the Vue/Inertia frontend.
How to Reproduce the Problem
Install a fresh Laravel project
laravel new test-api
Install Laravel Breeze or Laravel+Inertia Vue starter
php artisan breeze:install vue npm install npm run dev
Install API Platform for Laravel
composer require api-platform/laravel php artisan vendor:publish --tag=api-platform-config
Create any simple API resource
Example model:
#[ApiResource] class Bot extends Model { protected $fillable = ['name']; }
Generate the routes with Wayfinder
php artisan wayfinder:generate
Open the generated JS route file Usually located at:
resources/js/routes/api.php
Observe the problem
Wayfinder generates route definitions containing the unexpected {_format?} placeholder:
/api/bots/{id}{._format} /api/bots{._format}
Instead of clean routes like:
/api/bots /api/bots/{id}
Is there a way to generate pretty names for Laravel routes instead of ugly ones like
api/api/bots/{id}{._format}_patch
<?php
declare(strict_types=1);
namespace App\Metadata\Resource\Factory;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\OperationDefaultsTrait;
/**
* Decorator that removes trailing optional format placeholders added by the
* UriTemplate factory (e.g. "{._format}" / ".{_format}") so generated routes
* become "/api/bots" and "/api/bots/{id}" (better for SPA tooling like Wayfinder).
*
* It preserves user-defined templates (the UriTemplate factory marks them with
* the extra property 'user_defined_uri_template').
*/
final class NoFormatUriTemplateResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
{
use OperationDefaultsTrait;
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated)
{
}
public function create(string $resourceClass): ResourceMetadataCollection
{
$resourceMetadataCollection = $this->decorated->create($resourceClass);
foreach ($resourceMetadataCollection as $i => $resource) {
/** @var ApiResource $resource */
// Clean resource-level uriTemplate if it was generated (not user-defined)
if ($resource->getUriTemplate() && !($resource->getExtraProperties()['user_defined_uri_template'] ?? false)) {
$stripped = $this->stripFormatSuffix($resource->getUriTemplate());
if ($stripped !== $resource->getUriTemplate()) {
$resource = $resource->withUriTemplate($stripped);
}
}
$operations = new Operations();
foreach ($resource->getOperations() ?? new Operations() as $key => $operation) {
/** @var HttpOperation $operation */
// Respect user-defined operation templates
if ($operation->getUriTemplate() && !($operation->getExtraProperties()['user_defined_uri_template'] ?? false)) {
$strippedOp = $this->stripFormatSuffix($operation->getUriTemplate());
if ($strippedOp !== $operation->getUriTemplate()) {
$operation = $operation->withUriTemplate($strippedOp);
}
}
// keep operation names / keys as is
if ($operation->getName()) {
$operations->add($operation->getName(), $operation);
} else {
$operations->add($key, $operation);
}
}
$resource = $resource->withOperations($operations);
$resourceMetadataCollection[$i] = $resource;
}
return $resourceMetadataCollection;
}
private function stripFormatSuffix(string $uriTemplate): string
{
// Trim trailing "{._format}" (10 chars) or ".{_format}" (10 chars)
if (str_ends_with($uriTemplate, '{._format}') || str_ends_with($uriTemplate, '.{_format}')) {
return substr($uriTemplate, 0, -10);
}
return $uriTemplate;
}
}
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use App\Metadata\Resource\Factory\NoFormatUriTemplateResourceMetadataCollectionFactory;
class NoFormatUriTemplateServiceProvider extends ServiceProvider
{
public function register(): void
{
if ($this->app->bound(ResourceMetadataCollectionFactoryInterface::class)) {
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, function ($service, $app) {
return new NoFormatUriTemplateResourceMetadataCollectionFactory($service);
});
}
}
}
- The correct service name / binding to extend is the interface: ResourceMetadataCollectionFactoryInterface::class (see ApiPlatformDeferredProvider where the stack is constructed and returned).
- Install:
- Put the two files into your Laravel app (suggested paths above).
- Register the provider: add App\Providers\NoFormatUriTemplateServiceProvider::class to the providers array in config/app.php (or call $this->app->register(...) from your AppServiceProvider).
- Clear caches (php artisan config:clear && php artisan cache:clear) and regenerate frontend route helpers (Wayfinder) so they pick up the cleaned templates.