Laravel API resources for a Vue client

A resource is the JSON shape a SPA or Nuxt reads. An Inertia page can keep passing the model. Axios sees the rows on response.data.data.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-api-resources-vue.md
A resource is the JSON shape a SPA or Nuxt reads. An Inertia page can keep passing the model. Axios sees the rows on response.data.data.
Do not return the whole model and hope Vue ignores remembertoken style extras later.
Do not call Storage::url for a private file and hand the URL to any logged-in user.
Do not embed the entire author model.
Show the diff. Do not commit.

Next: A sortable Laravel and Vue table

On this page · 8 sections
  1. One post
  2. A page of posts
  3. What not to put in the resource
  4. Where this stops
  5. Recap
  6. FAQ
  7. Next steps
  8. Sources

Your Vue SPA calls /api/posts, logs the response, and the list is somehow on response.data.data. Or worse, a field you never meant to publish is in the JSON. API resources fix both: a fixed allow-list of keys and a predictable envelope.

TL;DR

  • A resource is the allow-list of keys an HTTP client sees. Inertia pages can keep passing models.
  • PostResource::collection($paginator) returns { data, links, meta }.
  • In Axios, response.data.data is the list: Axios envelope, then Laravel’s.
  • Never put private file URLs or whole related models in a resource.

What you'll learn

  • When you need a resource (and when Inertia does not)
  • How to shape one post and a paginated list
  • Why Axios rows are on response.data.data

Who it's for: Developers serving JSON to a Vue SPA or Nuxt

Applies to: Laravel 13 · Sanctum · Vue 3 · Axios

An Inertia controller can return the Post model. The page receives props, not a JSON document you designed. A resource is for the other client: a Vue SPA or Nuxt that calls /api/posts and has to know the keys. That split is Inertia, a SPA, or Nuxt. The cookie on that request is Sanctum. The Nuxt call is Nuxt in front of the API.

One post#

bash
php artisan make:resource PostResource
app/Http/Resources/PostResource.phpphp
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'excerpt' => $this->excerpt,
        'published_at' => $this->published_at,
    ];
}

Those four keys match the Post from the setup guide. Add a key here when the client needs it.

Leaks whatever the model has, then Only the keys you chose:

routes/api.phpphp
return Post::findOrFail($id);
routes/api.phpphp
return new PostResource(Post::findOrFail($id));

Gotcha: the resource is the allow-list

Do not return the whole model and hope Vue ignores remember_token style extras later. A resource is the allow-list.

A page of posts#

PostResource::collection on a paginator keeps Laravel’s links and meta and wraps each row.

routes/api.phpphp
Route::get('/posts', function () {
    $posts = Post::query()->latest('published_at')->paginate(10);

    return PostResource::collection($posts);
})->middleware('auth:sanctum');

The body is { data, links, meta }. data is the ten posts. links.next and meta.current_page are the paginator. Vue code that treats the body as an array will render nothing.

typescript
const response = await axios.get("/api/posts");
const posts = response.data.data;

Tip: the double data is expected

response.data is the HTTP body. response.data.data is the list. The double data is Laravel’s resource envelope, then Axios’s envelope. It is not a bug in either one.

meta.last_page is present because this example uses paginate(10). Pagination is what changes if you switch to simplePaginate or cursorPaginate. The resource does not add a total those paginators refused to count.

Pin the envelope in a test

tests/Feature/PostsApiTest.phpphp
it('returns a paginated resource', function () {
    Post::factory()->count(3)->create();

    $this->actingAs(User::factory()->create())
        ->getJson('/api/posts')
        ->assertOk()
        ->assertJsonStructure(['data' => [['id', 'title', 'excerpt', 'published_at']], 'links', 'meta']);
});

What not to put in the resource#

Do not call Storage::url for a private file and hand the URL to any logged-in user. Public covers from the upload guide can be a /storage/... path. Anything else stays behind a controller that checks the user.

Do not embed the entire author model. Pass author: $this->user?->only(['id', 'name']) when you add that relation. Until the post has a user, leave it out. The policy guide is what adds user_id.

Where this stops#

Heads up: one source per list

Inertia pages should not fetch this endpoint to paint the list they already receive as props. Two sources for the same rows will drift. Use the resource when the client is actually an HTTP client.

Recap#

  • Use a resource when the client is a real HTTP client.
  • toArray is the allow-list; add keys deliberately.
  • A paginated collection keeps links and meta; rows are on data.
  • Private files and full related models stay out.

FAQ#

Why is my Laravel data on response.data.data in Vue?

response.data is the Axios body; Laravel resources wrap rows in a data key. Both are by design.

Do I need API resources with Inertia?

Usually not. Inertia controllers pass props to the page. Use a resource when a separate SPA, Nuxt or a mobile app calls the endpoint.

Can I remove the data wrapper from Laravel resources?

JsonResource::withoutWrapping() removes it for single resources, but paginated collections still return data, links and meta. Most Vue clients just read data.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.