Skip to main content
Laravel, shipping fast.

A Resource is a class that turns an object into the JSON your consumers see. In most Laravel applications that object is an Eloquent model. Here it is a typed license object that came back from the provider, and the Resource works the same way. (Laravel documentation: Eloquent: API Resources.)

php artisan make:resource LicenseResource
// app/Http/Resources/LicenseResource.php
/** @property License $resource */
class LicenseResource extends JsonResource
{
    /**
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'key' => $this->resource->key,
            'name' => $this->resource->name,
            'domains' => $this->resource->domains,
            'created_at' => $this->resource->createdAt,
        ];
    }
}

A Resource is an explicit whitelist. Nothing leaves the API unless it is named in toArray().

That matters more than it looks. The object behind a Resource tends to grow: internal notes, a cost, a provider’s secret. Without a Resource, you are one return $license away from publishing all of it. With one, a new internal field stays internal until somebody deliberately adds a line here, and that line shows up in code review.

Resources also give you the envelope for free. Return one from a controller and Laravel wraps it:

{
    "data": {
        "key": "lic_789",
        "name": "New License",
        "domains": ["example.com"],
        "created_at": "2026-02-12T10:30:00Z"
    }
}

Return a collection and you get the same key around a list:

public function index(): AnonymousResourceCollection
{
    return LicenseResource::collection(Licenses::all());
}
{
    "data": [
        {
            "key": "lic_123",
            "name": "My License",
            "domains": ["example.com"],
            "created_at": "2026-02-12T10:30:00Z"
        }
    ]
}

One key, data, for one item or many. A consumer writes one parser and it works on every endpoint. And when a collection comes from a paginated Eloquent query, Laravel adds links and meta beside data without you writing a line. Licenses are never paginated, because the provider returns the full list, but Chapter 5’s consumers are, and the format is already decided.

If your consumers expect the JSON:API specification, Laravel 13 ships resource classes for that too. The rest of this book uses the plain format above. (Laravel documentation: Eloquent: API Resources › JSON:API Resources.)

Status Codes

A Resource returned from a controller answers 200. For an Eloquent model that was just created, Laravel answers 201 on its own. Our license isn’t a model, so store says it explicitly:

return LicenseResource::make($license)
    ->response()
    ->setStatusCode(Response::HTTP_CREATED);

Not every response carries a resource. Deleting a license means asking the provider to delete it, which can be slow, so the endpoint queues the work and answers immediately:

// app/Http/Controllers/LicenseController.php
public function destroy(string $license): JsonResponse
{
    DeleteLicense::dispatch($license);

    return response()->json(
        ['message' => __('licenses.delete_queued')],
        Response::HTTP_ACCEPTED,
    );
}

$license is the {license} segment of the URL, which is the license’s key. A 202 says “I received this and it will be processed.” A 200 would say “it’s done,” and that would be a lie. Chapter 10 builds DeleteLicense and explains what “will be processed” has to mean before you are allowed to promise it.

Why Not a Response Class of Your Own

You will see Laravel projects wrap every response in a class of their own: a SuccessResponse, an ApiResponse, a base class that owns the envelope. I’ve built those, and the service this book is drawn from still carries a set of them.

They are rarely worth it. Resources already own the envelope. response()->json() already owns the status and headers. Middleware already owns anything that must be added to every response. A wrapper class adds a fourth place where the shape of a response is decided, and the day it disagrees with the other three, you have the inconsistency you were trying to prevent.

Use what Laravel gives you until you can name the thing it can’t do. Then add exactly that.

The Whole Controller

// app/Http/Controllers/LicenseController.php
class LicenseController
{
    public function index(): AnonymousResourceCollection
    {
        return LicenseResource::collection(Licenses::all());
    }

    public function store(
        StoreLicenseRequest $request,
    ): JsonResponse {
        $license = Licenses::create(
            name: $request->validated('name'),
            domain: $request->validated('domain'),
        );

        return LicenseResource::make($license)
            ->response()
            ->setStatusCode(Response::HTTP_CREATED);
    }

    // ... destroy() as shown above
}

That is the entire HTTP layer for licenses. No provider URL, no token, no array keys, no if. A developer who reads index understands store.

Because the shapes are Laravel’s own, the tests are short too:

// tests/Feature/LicenseTest.php
it('rejects a license without a name', function () {
    $this->postJson('/api/licenses', ['domain' => 'a.com'])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['name']);
});

assertJsonValidationErrors knows the default error format. It works only because the API kept that format.

Chapter 2 Summary

  • A controller receives a request and returns a response. Nothing else.
  • Validation lives in a FormRequest. Messages live in the language files.
  • Keep Laravel’s validation error format, and force JSON for /api once, in bootstrap/app.php.
  • A Resource is the whitelist of what leaves the API, and it gives you the data envelope for one item or many.
  • Status codes are the controller’s decision: 201 for created, 202 for accepted and queued.
  • Don’t build a response layer of your own until you can name what Laravel’s can’t do.

The audio could not be loaded. Try again in a moment.