sylius/admin-api-bundle
Sylius Admin API Bundle exposes a RESTful administration API for Sylius e-commerce applications, with endpoints for managing products, orders, customers, and other resources to power headless admin interfaces and integrations.
First, the framework reality. sylius/admin-api-bundle is a Symfony bundle, not a Laravel package. composer require sylius/admin-api-bundle inside a Laravel project installs code that will never boot — there is no service provider, no config publish, no routes. You have exactly two ways to "use" it:
Version matrix as of v1.12.0. This release is a compatibility bump, not a feature release, and it narrows the field:
| Component | Supported now | Dropped in 1.12.0 |
|---|---|---|
| Sylius | 1.11, 1.12 | 1.10 |
| Symfony | 5.4 | 4.4, and 5.2/5.3 |
Before anything else, run composer show sylius/admin-api-bundle on the backend (or ask the vendor) and reconcile it against the Sylius version. A 1.10 shop cannot take this bundle version, and a 1.12 shop should be asking why you aren't on core's /api/v2/admin instead.
Standing up an instance to develop against (do this in a sidecar container, never in the Laravel repo):
composer create-project sylius/sylius-standard:^1.12 sylius-backend
cd sylius-backend
# enable SyliusAdminApiBundle in config/bundles.php,
# import @SyliusAdminApiBundle/Resources/config/app/admin_api.yml,
# register the sylius_admin_api firewall in config/packages/security.yaml
php bin/console debug:router | grep -i admin_api # discover real paths + token route
php bin/console sylius:fixtures:load -n
Where to look first, in this order:
Resources/config/app/admin_api/*.yml — one resource definition per entity (product, order, customer, taxonomy, payment method, …). This file is the API surface: it tells you which endpoints exist and which serialization groups shape the payload.Resources/config/routing.yml and the installed security.yaml — the URL prefix (/api/v1/... in most versions) and the token endpoint. Both drifted across Sylius releases; read them, don't guess.DependencyInjection/ compiler pass — explains which services you can decorate if you fork.First use case: authenticate and list products.
// config/services.php
'sylius' => [
'base_url' => env('SYLIUS_API_URL', 'http://sylius-backend.test'),
'client_id' => env('SYLIUS_API_CLIENT_ID'),
'client_secret'=> env('SYLIUS_API_CLIENT_SECRET'),
'username' => env('SYLIUS_API_USERNAME'),
'password' => env('SYLIUS_API_PASSWORD'),
],
$token = Http::asForm()->post(config('services.sylius.base_url').'/api/v1/oauth/v2/token', [
'grant_type' => 'password',
'client_id' => config('services.sylius.client_id'),
'client_secret' => config('services.sylius.client_secret'),
'username' => config('services.sylius.username'),
'password' => config('services.sylius.password'),
])->throw()->json('access_token');
$products = Http::withToken($token)
->get(config('services.sylius.base_url').'/api/v1/products', ['page' => 1, 'limit' => 10])
->throw()
->json();
If oauth/v2/token 404s, your Sylius build uses a different auth bundle — debug:router is the source of truth.
Wrap everything in a single class so retries, timeouts, logging and token refresh live in one place.
final class SyliusClient
{
public function __construct(private array $config, private Cache $cache) {}
public function http(): PendingRequest
{
return Http::baseUrl($this->config['base_url'])
->withToken($this->token())
->acceptJson()
->timeout(15)
->retry(3, 250, throw: false)
->withMiddleware(...); // or ->retry() callback to re-auth on 401
}
private function token(): string
{
return $this->cache->remember('sylius.token', now()->addMinutes(50), fn () => $this->fetchToken());
}
private function fetchToken(): string { /* as above, ->throw() */ }
}
Bind it as a singleton in a service provider. Cache the token — never fetch per request. On a 401, flush the cache key and retry once.
Don't scatter URL strings. A thin base class plus named subclasses keeps endpoint drift in one file.
abstract class Resource
{
public function __construct(protected SyliusClient $client) {}
abstract protected function endpoint(): string; // 'products', 'orders', 'customers', 'payment-methods'
public function index(array $query = []): array
{
return $this->client->http()->get($this->endpoint(), $query)->throw()->json();
}
public function show(string|int $id): array
{
return $this->client->http()->get("{$this->endpoint()}/{$id}")->throw()->json();
}
public function create(array $payload): array
{
return $this->client->http()->post($this->endpoint(), $payload)->throw()->json();
}
public function update(string|int $id, array $payload): void
{
$this->client->http()
->withHeaders(['Content-Type' => 'application/merge-patch+json'])
->patch("{$this->endpoint()}/{$id}", $payload)
->throw();
}
}
The application/merge-patch+json header is not decoration — JMS Serializer uses it to distinguish a partial patch from a full replace.
The API takes page, limit, sorting[field]=asc|desc, and criteria[...] filters. Build them from arrays and reject unknown keys before they reach the URL — Laravel's http_build_query will happily send garbage that yields confusing 400s.
$query = array_filter([
'page' => $page,
'limit' => $limit,
'sorting' => $sorting, // ['createdAt' => 'desc']
'criteria' => $criteria, // ['enabled' => true, 'name' => 'shirt']
], fn ($v) => $v !== null);
Walk pages until the returned collection is shorter than limit (or a next link is absent). Do it inside a queued job with a hard page cap so a bad filter can't loop forever.
foreach (range(1, 500) as $page) {
$batch = $this->products->index(['page' => $page, 'limit' => 100]);
if (empty($batch['_embedded']['items'] ?? $batch)) {
break;
}
// dispatch a child job per item, then continue
}
Poll with criteria[updatedAt][after]=<watermark> where the resource supports it; persist the watermark per resource. For everything else, do a nightly full page-walk into a staging table and diff. Never join Laravel and Sylius tables — they're separate databases by design.
v1.12.0 is the first release with full CRUD for payment methods. That makes a common Laravel need — "flip a gateway on for this channel from our admin panel" — newly possible without touching the Symfony side. Two things to plan for:
final class PaymentMethods extends Resource
{
protected function endpoint(): string { return 'payment-methods'; }
// Gateways carry a `gatewayConfig` blob whose shape depends on the Sylius
// gateway factory (stripe, paypal, offline…). Send it whole; don't model it.
public function toggle(string|int $id, bool $enabled): void
{
$this->update($id, ['enabled' => $enabled]);
}
}
GET /payment-methods once at deploy time and feature-flag the UI on the result rather than shipping a button that 404s.gatewayConfig is a per-factory shape. Keep it as raw passthrough in your DTOs, validate only the keys you actually display, and never round-trip a config you didn't construct — a PATCH that omits secrets may clear them.Capture one real response per endpoint and commit it. Http::fake() + Http::assertSent gives you a fast, deterministic suite; the Sylius side is too heavy to spin up in CI for most teams.
Http::fake([
'*/products*' => Http::response(json_decode(file_get_contents(base_path('tests/fixtures/products.json')), true)),
]);
$this->assertCount(3, app(Products::class)->index(['page' => 1])['_embedded']['items']);
Re-capture fixtures whenever the backend moves Sylius minor version — the envelope and field set are exactly what 1.11→1.12 changed.
POST JSON, then re-fetch by ID — the create response may omit associations.PATCH with merge-patch content type and only the changed fields.204; expect 409 if the resource is referenced (deleting a taxon with products, or a payment method in use, for example).POST or you will create twins on a retry.1.12.0 is a maintenance bump, not a feature release — plan accordingly. Two real changes: Sylius 1.11/1.12 support (1.10 dropped) and Symfony ^5.4 (4.4 and 5.2/5.3 dropped). If your backend container is still on Symfony 5.2, composer update will fail the constraint solve. Pin the bundle version alongside the Sylius version and upgrade them together, never independently.
A feature landed and was reverted in the same release. PR #20 added a serialized descriptor virtual property to Product; PR #23 reverted it. If you saw descriptor in a 1.12 pre-release, blog post, or a colleague's fixture, it does not exist in v1.12.0. Don't build a mapper around it — and this is the general lesson for a bundle with no maintainers: verify against a live response, never against release notes alone.
Archived and superseded — treat it as legacy. Zero dependents, a handful of stars, and the Admin API was folded into Sylius core (/api/v2/admin) for 1.12. This bundle exists to keep older shops running. New integrations should target core. Adopt this only if you're pinned to a Sylius you don't control — and if you're on 1.12, ask hard questions about why the bundle is installed at all.
Version skew is your biggest source of bugs. Token endpoint, URL prefix, response envelope and serialization group names all changed across Sylius releases — and 1.12 dropped whole Symfony/Sylius lines at once, so a backend on "1.12-ish" can be materially different from one on 1.11. Before writing a single mapper, run one authenticated request against the real instance and save the raw body. Build your DTOs from that, not from blog posts.
The collection envelope varies. Depending on HATEOAS/serializer config you'll get either _embedded.items or a bare JSON array. Write a unwrap() helper that handles both and use it everywhere — the alternative is a null-pointer bug the day the backend is upgraded.
415 Unsupported Media Type on PATCH means your content type is wrong, not your payload. application/json triggers a full replace; application/merge-patch+json gets you the partial update you expected.
Error shapes are Symfony-flavoured. Expect {"code": 400, "message": "..."} and validation failures as 400 rather than Laravel's 422. Normalise into your own exception class at the client boundary and never surface the raw message to end users — it leaks internal entity names.
Money is integer minor units and currency-tagged. Cast to integer, divide only at the presentation layer, and carry the currency code alongside. Casting to float anywhere in the chain is how you get one-cent rounding drift in an order total.
No idempotency, no ETags, no built-in throttling. You get none of these for free. Do dedupe checks in Laravel, cache reads with a short TTL busted on writes, and wrap the client in a RateLimiter.
Stateless auth only. No cookies, no CSRF, no session. If a request behaves oddly from a browser, you're almost certainly sending cookies from a Sanctum session and hitting a firewall that rejects them.
Pin exact versions and vendor-fork if needed. A release cadence of roughly one maintenance PR per year means an upstream patch is unlikely; 1.12.0 itself is mostly dependency housekeeping. If you require a fix beyond a version bump, fork and use a VCS repositories entry — don't hope for a release.
Extension points, if you own the Symfony side: override resource definitions in your app's config/packages/ rather than editing the vendor bundle, decorate services to add fields to serialization groups, and add custom operations per resource in the admin_api YAML. All three survive a composer update; direct vendor edits do not.
Debugging checklist when a call fails: (1) confirm the route with bin/console debug:router, (2) confirm the firewall name in security.yaml, (3) log the raw response before your mapper touches it, (4) check whether the resource is even registered in admin_api/*.yml — a 404 often means "not exposed", not "wrong URL", and on 1.11 that's the expected answer for payment methods.
How can I help you explore Laravel packages today?