xing-com/api-resource-bundle
Xing Com API Resource Bundle is a Symfony bundle for building resource-oriented REST APIs. It helps expose Doctrine-backed resources through configurable endpoints, with support for routing, serialization, pagination, and consistent API responses, reducing boilerplate for CRUD-style services.
xing-com/api-resource-bundle is a Symfony bundle, not a Laravel package. It ships resource objects (ApiResource subclasses), a ResourceSerializer/ResourceDeserializer pair built on top of symfony/serializer, and HAL-flavoured output (_links, _embedded). There is no ApiResourceServiceProvider, no config/api-resource.php to publish, and no facade. Treat it as a library you wire up yourself.
Install
composer require xing-com/api-resource-bundle \
symfony/serializer symfony/property-access symfony/property-info
Check composer.json in the package for the PHP/Symfony constraints before committing — the 2025-09 release targets PHP 8.2+ and Symfony 6/7, and the transitive Symfony components will coexist with Laravel's own symfony/* pins. Run composer why symfony/serializer afterwards to confirm you didn't downgrade anything Laravel needs.
Where to look first
vendor/xing-com/api-resource-bundle/src/ — the source is small; read the base resource class and the serializer/deserializer first.vendor/xing-com/api-resource-bundle/tests/ — with three stars and zero dependents, the test suite is the only reliable documentation of the attribute syntax and payload shape.DependencyInjection/) — it tells you exactly which services and constructor arguments you need to replicate in a Laravel service provider.Wire it once in a provider
// app/Providers/ApiResourceServiceProvider.php
public function register(): void
{
$this->app->singleton(ResourceSerializer::class, function ($app) {
return new ResourceSerializer(
new Serializer(
[new ObjectNormalizer(null, null, null, new ReflectionExtractor())],
[new JsonEncoder()]
),
$app->make(ResourceFactory::class),
);
});
$this->app->singleton(ResourceDeserializer::class, fn ($app) => new ResourceDeserializer(
$app->make(Serializer::class),
$app->make(ResourceMetadataFactory::class),
));
}
(Adjust namespaces to whatever your installed version exposes — verify against src/ rather than the README.)
First use case: replace a hand-rolled JsonResource on one read endpoint. Define BookResource, build it from an Eloquent model, serialize it, return response()->json($payload). Compare the output against what your client currently consumes before you touch a second endpoint.
The core pattern is a plain PHP object with typed properties and getters. Links are declared with PHP attributes (older docs use annotations — attributes only in current releases).
use Xing\ApiResourceBundle\...\ApiResource;
use Xing\ApiResourceBundle\...\ResourceLink;
use Xing\ApiResourceBundle\...\ResourceCollection;
class BookResource extends ApiResource
{
public function __construct(
public string $id,
public string $title,
public AuthorResource $author,
public ResourceCollection $reviews,
) {}
#[ResourceLink(relation: 'self', href: '/books/{id}')]
public function getSelfLink(): ResourceLink { /* ... */ }
}
Keep resources dumb: no queries, no Auth::user(), no now(). They are DTOs; the moment they touch the container you lose the ability to unit test serialize() output in isolation.
toResource() method on modelsEloquent models stay clean; mapping lives in one place:
class BookResourceFactory
{
public function fromModel(Book $book): BookResource
{
return new BookResource(
id: (string) $book->id,
title: $book->title,
author: $this->authorFactory->fromModel($book->author),
reviews: new ResourceCollection(
$book->reviews->map(fn ($r) => $this->reviewFactory->fromModel($r))->all()
),
);
}
}
This is also your natural seam for lazy/eager loading: the factory is where you assert relations are loaded, and where a missing relation fails loudly instead of producing a half-empty payload.
public function show(Book $book, BookResourceFactory $factory, ResourceSerializer $serializer)
{
$payload = $serializer->serialize($factory->fromModel($book));
return response()->json($payload)
->header('Content-Type', 'application/hal+json');
}
Add a small HalResponse helper or a respondWithResource() trait so the media type and status code are set in one place — clients will depend on application/hal+json, and getting it wrong on one endpoint is the classic bug.
Build one ResourceCollection per page and attach pagination links at the collection level rather than per item:
$collection = new ResourceCollection($resources);
// attach first/prev/next/last + total, mirroring Laravel's paginator
Map Laravel's LengthAwarePaginator onto the collection in a single helper. Keep the query-string contract (page, per_page) identical to the rest of your API so HAL links don't invent a second pagination dialect.
try {
$dto = $deserializer->deserialize($request->json()->all(), CreateBookResource::class);
} catch (/* deserialization/mapping exception */ $e) {
throw new HttpResponseException(
response()->json(['error' => 'invalid_payload', 'detail' => $e->getMessage()], 422)
);
}
Wire this into a FormRequest or a middleware rather than repeating the try/catch. Prefer this only where the input genuinely mirrors a resource (nested writes, PUT-with-representation). For flat create/update payloads, $request->validate() plus a constructor is less machinery for the same result.
Default to links, opt in to _embedded per endpoint (e.g. ?embed=author). The serializer will happily recurse through Book → Author → Books → Author; decide the depth explicitly in the factory rather than letting the object graph decide for you.
services.yaml you must bind by hand, and compiler passes have no counterpart at all. Budget an afternoon for wiring, not ten minutes.ObjectNormalizer (property info, #[Groups], #[SerializedName]) is unrelated to Laravel's JsonResource/#[Hidden]. Pick one per endpoint; nesting an ApiResource inside a JsonResource produces double-wrapped payloads that are painful to diagnose.@ResourceLink annotations. Current releases expect PHP 8 attributes and there is no annotation reader installed by default — a silently ignored attribute means a link simply disappears from _links, with no error.dump($serializer->serialize($resource)) then json_encode yourself; it separates serialization bugs from response/encoding bugs.composer.json, and keep the factory layer thick enough that swapping to spatie/laravel-data, Fractal, or a hand-rolled JsonResource is a one-directory change._links/_embedded, Laravel's built-in JsonResource or spatie/laravel-data gives you the same DTO discipline with first-class framework support, validation integration and a community to ask. This bundle earns its place only when a client contract already speaks HAL.ObjectNormalizer (for Carbon/Money/enums), a bespoke ResourceFactory for a shared resource hierarchy, and substituting your own metadata factory if you need runtime class discovery. All three are plain constructor arguments — no decoration or compiler passes required, which is the bundle's one genuine ergonomic advantage.singleton plus an in-memory array keyed by class name, and clear it in tests with RefreshDatabase-style resets if you memoize across requests in Octane.How can I help you explore Laravel packages today?