JSON:API Controller

JsonApiController provides generic CRUD operations following the JSON:API specification: request/response formatting, resource validation, and a per-attribute write-authorization gate. Extend it when an entity needs a CRUD API and you want explicit control over which fields a client can set, independent of the entity's own getters and setters.

explanation

Basic Usage

Extend JsonApiController, implement getEntityClass() and getResourceType(), and route your own methods to the inherited getResource(), createResource(), updateResource(), and deleteResource() helpers:

<?php
namespace App\Controllers;

use App\Entities\ProductEntity;
use Quellabs\Canvas\Controllers\JsonApiController;
use Quellabs\Canvas\Annotations\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;

class ProductController extends JsonApiController {

    protected function getEntityClass(): string {
        return ProductEntity::class;
    }

    protected function getResourceType(): string {
        return 'products';
    }

    /**
     * @Route("/api/products/{id}", methods={"GET"})
     */
    public function show(int $id): JsonResponse {
        return $this->getResource($id);
    }

    /**
     * @Route("/api/products", methods={"POST"})
     */
    public function store(Request $request): JsonResponse {
        return $this->createResource($request);
    }

    /**
     * @Route("/api/products/{id}", methods={"PUT", "PATCH"})
     */
    public function update(int $id, Request $request): JsonResponse {
        return $this->updateResource($id, $request);
    }

    /**
     * @Route("/api/products/{id}", methods={"DELETE"})
     */
    public function destroy(int $id): JsonResponse {
        return $this->deleteResource($id);
    }
}

The four helpers are protected on purpose — your controller decides the routes and HTTP methods that call them, nothing is auto-routed.

MethodBehavior
getResource(int $id)Loads the entity by ID and serializes it, or returns 404 if it doesn't exist.
createResource(Request $request)Validates the JSON:API body, authorizes and validates attributes, persists a new entity, and returns it with 201.
updateResource(int $id, Request $request)Loads the entity, checks the body's id against the URL (if present), authorizes and validates attributes, flushes the changes, and returns the updated entity.
deleteResource(int $id)Removes the entity and returns 204, or 404 if it doesn't exist.

Write Authorization

Every attribute submitted to createResource() or updateResource() passes through validation before anything is written to the entity. Validation matches the submitted keys against the entity's declared properties only — a setter does not define an attribute beyond what the entity declares:

  • A key that doesn't case-insensitively match any declared property is unknown — the whole request is rejected with 422 (typo, stale client field).
  • A key that matches a declared property only with different casing is forbidden with 403, never silently coerced to the canonical casing — there's no reliable way to recover the "correct" casing from an acronym setter (setID() could back property id or iD).
  • The entity's primary key is always forbidden with 403, regardless of isAttributeWritable() — a client can never reassign which row a request writes to.
  • Every other declared property is decided by isAttributeWritable(), which defaults to allowing everything.

Override isAttributeWritable() to deny specific fields, operations, or circumstances:

use Quellabs\Canvas\Controllers\WriteOperation;

protected function isAttributeWritable(object $entity, string $key, WriteOperation $operation): bool {
    // Keep role server-owned
    if ($key === 'role') {
        return false;
    }

    // Allow setting the slug only when creating, never on update
    if ($key === 'slug') {
        return $operation === WriteOperation::Create;
    }

    return true;
}

Whether an attribute has a setter or not plays no role in this decision — it only affects how mapAttributesToEntity() writes the value afterwards: through the setter when one exists, directly via reflection otherwise.

Resource-Level Authorization

For checks a per-attribute decision can't express — ownership, role, or current entity state — override authorizeAttributeWrite(). It runs before any attribute is validated or mapped, so it can deny a write purely based on the resource, regardless of which fields were submitted:

use Quellabs\Canvas\Controllers\WriteOperation;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;

protected function authorizeAttributeWrite(Request $request, object $entity, WriteOperation $operation): ?JsonResponse {
    if ($operation === WriteOperation::Update && $entity->getOwnerId() !== $this->currentUserId()) {
        return $this->forbidden('You do not own this resource');
    }

    return null;
}

Error Responses

Errors follow the JSON:API error object format, with a source.pointer identifying the offending attribute where applicable:

StatusConditionPointer
400Request body is not valid JSON, or the update body's id doesn't match the URL—
403An attribute is the primary key, has mismatched casing, or isAttributeWritable() denies it/data/attributes/{name}, one error per attribute
404The requested resource doesn't exist—
409The request body's data.type doesn't match getResourceType()—
422Invalid request structure, or an attribute name doesn't match any declared property/data/attributes, one error listing all unknown names

Generating Controllers from Entities

Running php vendor/bin/sculpt make:pac-entity EntityName (provided by ObjectQuel) scaffolds a JsonApiController subclass alongside the WakaPAC JavaScript abstraction. The generated isAttributeWritable() stub returns true — matching the allow-by-default base — and lists the entity's non-identifier properties in a comment purely as a starting point for review; no field is denied automatically.