core icon indicating copy to clipboard operation
core copied to clipboard

When integrating API Platform with Wayfinder + Inertia/Vue in a SPA architecture, several issues arise due to how API Platform generates routes.

Open itaginsexordium opened this issue 2 months ago • 1 comments

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.

Image Image

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}

itaginsexordium avatar Dec 09 '25 10:12 itaginsexordium

Is there a way to generate pretty names for Laravel routes instead of ugly ones like

api/api/bots/{id}{._format}_patch

itaginsexordium avatar Dec 09 '25 10:12 itaginsexordium

<?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.

soyuka avatar Dec 11 '25 14:12 soyuka