- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You're three sprints into a backend rewrite. The product side wants a JSON API by Friday, an OpenAPI spec the mobile team can codegen against, and "maybe GraphQL later, we'll see." You've shipped Laravel APIs for years. You've heard Symfony folks rave about API Platform. The honest question is whether either choice still earns its keep in 2026, or whether you're paying for things you won't use.
Here's what nobody tells you up front. The two stacks aren't aiming at the same target. API Platform is an opinion about how APIs should look. Laravel Resources are a transformer with toArray. That difference shapes every decision downstream.
What each one actually does
API Platform (3.4 as of writing, 4.0 in beta) takes a Doctrine entity, reads PHP attributes off it, and gives you back a working REST API. Content negotiation (JSON:API, HAL, JSON-LD), an OpenAPI 3.1 schema, a Swagger UI page, and, if you want it, a GraphQL endpoint. It generates the controllers. It wires pagination. It does filtering. It handles validation errors as RFC 7807 problem details. You don't write index, show, store, update, destroy. They exist already.
Laravel's API Resources are different in kind. A resource is a class that turns one Eloquent model into one array. A resource collection wraps a paginator. You still write the controller. You still wire the routes. You still pick the response shape. What you get is $this->whenLoaded('seller') so you don't accidentally trigger N+1, and a clean place to put the JSON contract instead of bleeding it into the controller.
So when someone benchmarks "API Platform vs Laravel Resources" on lines of code, API Platform wins by 4x. Easily. That number is misleading because most of the saved code is opinionated default behaviour you'll fight against the moment your API doesn't look like a generic REST collection.
A small CRUD example, side by side
Same resource. Real one. Product with a seller relation, a category, and a stock count. Both stacks need: list, get one, create, update, soft-delete.
Symfony API Platform, src/Entity/Product.php:
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new Post(),
new Patch(),
new Delete(),
],
normalizationContext: ['groups' => ['product:read']],
denormalizationContext: ['groups' => ['product:write']],
paginationItemsPerPage: 25,
)]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'partial',
'category.slug' => 'exact',
'seller.id' => 'exact',
])]
#[ApiFilter(RangeFilter::class, properties: ['priceCents'])]
class Product
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['product:read'])]
private ?int $id = null;
#[ORM\Column(length: 200)]
#[Assert\NotBlank, Assert\Length(max: 200)]
#[Groups(['product:read', 'product:write'])]
private string $name;
#[ORM\Column]
#[Assert\PositiveOrZero]
#[Groups(['product:read', 'product:write'])]
private int $priceCents;
#[ORM\Column]
#[Assert\PositiveOrZero]
#[Groups(['product:read', 'product:write'])]
private int $stock;
#[ORM\ManyToOne(inversedBy: 'products')]
#[Groups(['product:read', 'product:write'])]
private Seller $seller;
#[ORM\ManyToOne]
#[Groups(['product:read', 'product:write'])]
private Category $category;
// getters/setters omitted for brevity
}
That's it. Routes, controllers, validation, filtering on name, category.slug, seller.id, range filtering on priceCents, pagination at 25 per page, OpenAPI generation, JSON:API output if you ask for Accept: application/vnd.api+json. All from one attribute block. Visit /api and there's a Swagger UI page. Visit /api/products?name=widget&priceCents[gte]=500, you get filtered results. Free.
Laravel API Resources, same surface, four files.
app/Models/Product.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use SoftDeletes;
protected $fillable = ['name', 'price_cents', 'stock', 'seller_id', 'category_id'];
public function seller(): BelongsTo
{
return $this->belongsTo(Seller::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
app/Http/Resources/ProductResource.php:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price_cents' => $this->price_cents,
'stock' => $this->stock,
'seller' => new SellerResource($this->whenLoaded('seller')),
'category' => new CategoryResource($this->whenLoaded('category')),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}
app/Http/Controllers/Api/ProductController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$query = Product::with(['seller', 'category']);
if ($name = $request->query('name')) {
$query->where('name', 'like', "%{$name}%");
}
if ($slug = $request->query('category_slug')) {
$query->whereHas('category', fn ($q) => $q->where('slug', $slug));
}
if ($sellerId = $request->query('seller_id')) {
$query->where('seller_id', $sellerId);
}
if ($min = $request->query('price_min')) {
$query->where('price_cents', '>=', (int) $min);
}
if ($max = $request->query('price_max')) {
$query->where('price_cents', '<=', (int) $max);
}
return ProductResource::collection($query->paginate(25));
}
public function show(Product $product)
{
return new ProductResource($product->load(['seller', 'category']));
}
public function store(StoreProductRequest $request)
{
$product = Product::create($request->validated());
return new ProductResource($product->load(['seller', 'category']));
}
public function update(UpdateProductRequest $request, Product $product)
{
$product->update($request->validated());
return new ProductResource($product->load(['seller', 'category']));
}
public function destroy(Product $product)
{
$product->delete();
return response()->noContent();
}
}
Plus two FormRequest classes for StoreProductRequest and UpdateProductRequest. Plus a route line: Route::apiResource('products', ProductController::class);. Roughly 4x the LOC of the Symfony version for the same API surface. None of it is hard. All of it is hand-written.
That ratio shifts the second the API stops being uniform.
Customisation, when API Platform's defaults bite
API Platform's superpower is its default behaviour. Its weakness is the same thing. The moment your product needs a list response that doesn't look like API Platform's list response, you're learning the framework instead of writing code.
Concrete example. The mobile team wants the /products collection to include a total_value_cents summary field. That's sum of price_cents * stock across the entire filtered result set, not just the current page. In Laravel you write three lines in the controller and stick it in the meta block of the resource collection. Done.
In API Platform you write a custom Provider:
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Pagination\Pagination;
use App\Entity\Product;
use App\Repository\ProductRepository;
final class ProductCollectionProvider implements ProviderInterface
{
public function __construct(
private ProductRepository $products,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$filters = $context['filters'] ?? [];
$page = $this->pagination->getPage($context);
$perPage = $this->pagination->getLimit($operation, $context);
// returns a custom paginator with totalValueCents in the meta
return $this->products->paginatedWithTotalValue($filters, $page, $perPage);
}
}
And register it on the entity:
#[ApiResource(
operations: [
new GetCollection(provider: ProductCollectionProvider::class),
// ...
],
)]
Then you need a custom normaliser to fold the meta into the JSON-LD or Hydra response shape. At that point you're four classes deep into framework internals that the docs cover but no Stack Overflow answer does. The Laravel version is six lines of controller code.
The same pattern applies to nested write operations (creating a product with its seller in one POST), polymorphic relations, anything that needs cross-resource validation, anything where the URL pattern doesn't match the entity shape. API Platform has answers for all of it. The answers cost more than writing it yourself in Laravel would.
Performance: N+1 prevention
This is where the two stacks differ in a way that bites production hardest.
Laravel's N+1 problem with API Resources is opt-in. If you write new ProductResource($product) without $product->load(['seller', 'category']), every nested whenLoaded returns a MissingValue and you ship a response with null relations. Worse, if you forget the whenLoaded and access the relation directly, you get a query per row. The fix is with() in the controller's query builder. Boring, easy to forget.
Diff for the fix:
// before: 1 + N queries for a 25-product page
$products = Product::paginate(25);
return ProductResource::collection($products);
// after: 1 + 2 queries total
$products = Product::with(['seller', 'category'])->paginate(25);
return ProductResource::collection($products);
Laravel 12 added Model::preventLazyLoading() (available since 8.43, hardened in 12.x), which throws LazyLoadingViolationException in non-production environments if you ever access an unloaded relation. Turn it on in AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
That's the one Laravel control you should never ship without. It turns "silent 200-query response" into "loud test failure."
API Platform's N+1 problem is the other shape. The framework's Doctrine extensions try to eager-load based on serialization groups, but the moment your serializer walks into a relation that wasn't part of the original DQL query, Doctrine fires a lazy fetch. The fix is EagerLoadingExtension (on by default since 2.6) plus explicit joins in a custom Doctrine query extension when the default heuristic misses.
Diff:
<?php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Product;
use Doctrine\ORM\QueryBuilder;
final class ProductEagerExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(
QueryBuilder $qb,
QueryNameGeneratorInterface $names,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if ($resourceClass !== Product::class) {
return;
}
$alias = $qb->getRootAliases()[0];
$qb->addSelect('seller', 'category')
->leftJoin("{$alias}.seller", 'seller')
->leftJoin("{$alias}.category", 'category');
}
}
Register it as a tagged service (api_platform.doctrine.orm.query_extension.collection) and the framework wires it. Once it's in place, every /api/products request issues one query with the joins instead of 1 + 2N.
The trap is that you only notice once you turn on Doctrine's SQL logger or hit the New Relic timeline. Both frameworks have N+1 issues. Laravel's is louder because the queries are obvious in php artisan db:show. Symfony's is quieter because the queries hide inside Doctrine's hydration phase.
GraphQL: API Platform free, Laravel needs Lighthouse
Add #[ApiResource(graphQlOperations: [new Query(), new QueryCollection(), new Mutation(name: 'create')])] to the entity. Visit /api/graphql. There's a GraphiQL playground, schema introspection works, the same filters work, the same security voters apply. Free is overstating it slightly (you'll write resolvers for anything custom), but the boilerplate is gone.
Laravel's GraphQL story is Nuwave Lighthouse (nuwave/lighthouse ~6.x). It's good. It's also a separate framework you bolt on, with its own schema-first language (schema.graphql), its own directives, its own pagination conventions, and its own N+1 prevention via @dataloader. The code volume to expose the same Product query is in the hundreds of lines once you count types, directives, and resolvers.
If GraphQL is on the roadmap and not negotiable, API Platform's bundling is the strongest argument for picking it. If GraphQL is a "maybe later", Lighthouse is fine and you don't pay for it until you adopt.
OpenAPI: generation quality
API Platform generates OpenAPI 3.1 from the same attributes that generate the routes. The spec is correct by construction. The API can't drift from the docs because there's only one source. Tools downstream (Stoplight, openapi-typescript, Kiota, Speakeasy) eat it cleanly. Mobile teams codegen against it without filing tickets.
Laravel's OpenAPI story is two main packages: vyuldashev/laravel-openapi (attribute-driven, requires you to annotate controllers) or scramble/scramble (zero-config, infers from FormRequests and Resources). Scramble in particular got good around 0.10. It's the closest you'll get to free OpenAPI in Laravel. It reads your FormRequest::rules(), your JsonResource::toArray(), and writes a spec that's about 85% accurate out of the box.
The 15% gap is what shows up at the integration boundary. Nullable fields, polymorphic relations, custom response envelopes. Scramble guesses. API Platform doesn't guess because the attributes are the contract.
The honest verdict
You'd pick API Platform when:
- The API surface is broad and uniform: many CRUD-shaped resources, standard filters, standard pagination.
- You want OpenAPI and GraphQL on day one, not in sprint 12.
- The team is already comfortable with Doctrine and Symfony patterns.
- API-first is the product, not a side door.
You'd pick Laravel Resources when:
- The JSON shape is bespoke per endpoint: different fields for mobile vs web, different envelopes for legacy clients, different pagination meta.
- The product is a Laravel app that happens to also expose an API. The API is one of many surfaces, not the surface.
- Your team's pattern is service classes and explicit controllers, not framework-driven inversion of control.
- You'd rather write 4x the code and own every byte than fight an opinionated framework when it disagrees with your product.
Neither one is wrong. The choice you regret is the one where you didn't notice the trade. If you pick API Platform and your API ends up being 60% custom providers and normalizers, you've paid the framework tax twice. Once to learn it, once to escape it. If you pick Laravel Resources and your team writes the same five lines of controller code on every endpoint for two years, you've paid the boilerplate tax in slower velocity and inconsistent error responses.
Look at the next 12 months of work. If it's "build 30 mostly-uniform endpoints over a Doctrine model", API Platform earns its keep. If it's "expose 8 endpoints with very specific shapes the mobile team negotiated line by line", Laravel Resources earn theirs.
What's your call: did you pick the opinionated framework and end up writing custom providers, or pick the explicit one and end up writing the same controller five times?
If this was useful
The two frameworks model the same problem with different philosophies: opinionated defaults vs explicit control. When the API outgrows what either gives you, the next move usually isn't a different framework. It's pulling the resource shape, the validation rules, and the persistence concerns into a domain layer that neither stack owns. That's the architectural layer your codebase reaches for after it outgrows the framework defaults, and it's what Decoupled PHP is about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now. Portuguese and Spanish coming soon.

Top comments (0)