A Laravel mobile app backend is a versioned REST API built from five parts. You need Sanctum bearer tokens issued per device, API Resources that fix the JSON your app parses, cursor pagination for feeds, consistent JSON errors, and push notifications sent from queues. Get those five right and Laravel will serve a Flutter, Swift, or Kotlin app for years.

Most tutorials skip one fact. You can redeploy a web frontend in minutes, but you can't redeploy the app on someone's phone. A build from six months ago still calls your API today, and it expects the exact field names it shipped with.

You already know Laravel makes CRUD fast. Routes, controllers, and Eloquent models come together in an afternoon. This guide covers what changes when the client is a mobile app, with runnable code for each layer: versioned setup, device authentication, response contracts, error handling, push, testing, and deployment.

Key TakeawaysPut routes under /api/v1 before your first release, because app builds stay installed long after you deploy a new backend.Issue one Sanctum token per device with a name, an expiry, and abilities, so a lost phone can be revoked without logging out every other device.Treat API Resources as the contract with your app, use cursorPaginate() for feeds, and never rename a field inside a published version.Return JSON errors on every API route, throttle per user and per IP, and send push notifications from queued jobs.

Why a Laravel mobile app backend needs a different design

A web backend and its frontend deploy together. Rename a column, update the Blade view or React component in the same pull request, and every user gets both changes on the next page load. Mobile breaks that assumption in five ways.

Concern

Web frontend

Mobile app

Release timing

Ships with the backend

Waits for app store review and user updates

Old clients in use

Gone after a refresh

Still installed months later

Authentication

Session cookie and CSRF

Bearer token stored on the device

Network

Mostly stable connections

Patchy cellular, timeouts, and retries

Payload cost

Cheap

Battery, data plan, and parse time

Here's how that bites. A two-person team ran a fitness class booking system with a Laravel API behind a Flutter app. During a cleanup sprint, one developer renamed start_time to starts_at and updated the admin dashboard in the same commit.

The dashboard worked fine. Every installed copy of the app crashed on the schedule screen, because its JSON parser still looked for start_time. The fix took ten minutes, but getting it onto phones took four days of store review and update prompts.

The lesson isn't "never refactor." It's that the JSON your app parses is a public contract, so you version it and change it on purpose. The rest of this guide follows from that.

Want to see this structure generated as a real Laravel project? Try StackMivo Now and describe your mobile API in plain English.

Set up a versioned Laravel API for a mobile app

The first piece of a Laravel mobile app backend is a clean API layer. On Laravel 11 and newer it's opt-in: one Artisan command installs Sanctum, adds the personal access tokens migration, and creates routes/api.php. Laravel 9 and 10 include both in the default skeleton, so skip the first line there.

php artisan install:api
php artisan make:model Booking -mf --policy
php artisan make:controller Api/V1/BookingController --api --model=Booking --requests
php artisan make:resource V1/BookingResource

The --api flag skips the create and edit methods a mobile client never calls. The --requests flag generates StoreBookingRequest and UpdateBookingRequest for validation. If the flags blur together, our php artisan make:model cheatsheet explains every option.

Next, group the routes under a version prefix. Laravel already adds /api, so these resolve to /api/v1/bookings.

<?php

use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\BookingController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->name('v1.')->group(function () {
	Route::post('login', [AuthController::class, 'login'])->middleware('throttle:login');

	Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
		Route::post('logout', [AuthController::class, 'logout']);
		Route::apiResource('bookings', BookingController::class);
	});
});

When a breaking change can't be avoided, add a v2 group with its own controllers and resources, and keep v1 running until your analytics show old builds have faded. Pair that with a minimum-version endpoint the app calls on launch (it returns the oldest build you still support), so you retire versions on purpose instead of by accident.

You can write this scaffold by hand in half an hour. You can also describe it as "a v1 bookings API with Sanctum auth, FormRequests, and Pest tests" and let AI code generation produce it as a production-ready Laravel app with models, migrations, controllers, and tests you own.

Authenticate each device with Sanctum tokens

Mobile apps don't carry your domain's session cookies, so session auth is out. Sanctum's mobile application authentication flow fits instead. The app posts credentials plus a device name, and the API returns a plain-text bearer token that the app stores and sends on every request.

public function login(Request $request): JsonResponse
{
	$request->validate([
		'email' => ['required', 'email'],
		'password' => ['required', 'string'],
		'device_name' => ['required', 'string', 'max:100'],
	]);

	if (! Auth::once($request->only('email', 'password'))) {
		throw ValidationException::withMessages([
			'email' => ['These credentials do not match our records.'],
		]);
	}

	$token = Auth::user()->createToken(
		$request->input('device_name'),
		['bookings:read', 'bookings:write'],
		now()->addDays(30),
	);

	return response()->json([
		'token' => $token->plainTextToken,
		'expires_at' => $token->accessToken->expires_at->toIso8601String(),
	], 201);
}

public function logout(Request $request): Response
{
	$request->user()->currentAccessToken()->delete();

	return response()->noContent();
}

Three details in that controller pay off later.

  • Device names let a user revoke "Work iPhone" from an account screen without logging out their tablet.

  • Expiry matters because Sanctum tokens never expire by default, so set one and schedule sanctum:prune-expired to clean up old rows.

  • Abilities such as bookings:read let your Policies call tokenCan() before a write goes through.

On the client, keep the token in the platform keychain, not in plain preferences. In Flutter that means a package like flutter_secure_storage, plus an Authorization: Bearer header on each call, as the Flutter guide to making authenticated requests shows. Send Accept: application/json too, since Laravel reads it to decide how errors render.

Use API Resources and cursor pagination as the contract

Returning $booking->toArray() from a controller ties your app to your database schema. Add a column and it leaks into the response. Rename one and old builds break. An API Resource sits between the model and the JSON, so the schema can change while the contract holds.

public function toArray(Request $request): array
{
	return [
		'id' => $this->id,
		'status' => $this->status,
		'starts_at' => $this->starts_at->toIso8601String(),
		'class' => new FitnessClassResource($this->whenLoaded('fitnessClass')),
		'created_at' => $this->created_at->toIso8601String(),
	];
}

Two habits keep this resource mobile-friendly. Dates go out as ISO 8601 strings with a timezone offset, so Dart and Swift decoders read them without guesswork. Relationships use whenLoaded(), so class details appear only when the controller eager loads them, which also blocks the N+1 queries that turn a fast endpoint into a slow one.

This is where Laravel-native intelligence matters if you use AI. A generic assistant might return $model->toArray() and move on. A tool trained on the Laravel ecosystem reaches for API Resources, FormRequests, and eager loading by default.

Feeds need one more decision. Offset pagination works for admin tables, but it misbehaves on a phone. If new bookings arrive while the user scrolls, every row shifts and the next page repeats items they already saw. Cursor pagination fixes that by asking for rows after the last ID instead of skipping a count.

public function index(Request $request): AnonymousResourceCollection
{
	$bookings = $request->user()
		->bookings()
		->with('fitnessClass')
		->orderByDesc('id')
		->cursorPaginate(20);

	return BookingResource::collection($bookings);
}

Order by a unique column such as id, because cursor pagination needs a unique sort. The response includes next_cursor in meta and a ready-made URL in links.next, and the app follows it to fetch the next batch.

A Laravel team lead at a grocery delivery startup learned this during an evening rush. Drivers kept reporting duplicate orders in the app's job list. The API used paginate(20), and fresh orders landing at the top pushed earlier ones onto the next page.

Switching the endpoint to cursorPaginate(20) ordered by id removed the duplicates. Because the app already followed the links.next URL from each response, the fix shipped without a new app release.

Handle errors, rate limits, and push in a Laravel backend for Flutter

Mobile networks drop requests, retry them, and now and then send them twice. A Laravel backend for Flutter, Swift, or Kotlin clients should answer every failure in the same JSON shape, so the app shows a useful message instead of a blank screen.

Return JSON errors on every API route

Laravel picks HTML or JSON based on the Accept header. Forget that header in one app build, and a validation failure returns a redirect the app can't parse. On Laravel 11 and newer, force JSON for everything under /api in bootstrap/app.php.

->withExceptions(function (Exceptions $exceptions) {
	$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
		if ($request->is('api/*')) {
			return true;
		}

		return $request->expectsJson();
	});
})

Validation failures now return a 422 with a message and an errors object keyed by field, which maps cleanly onto form fields in the app. On Laravel 9 and 10, override shouldReturnJson() in app/Exceptions/Handler.php to get the same result.

Rate limit per user and per IP

A buggy retry loop in one app release can hit your API as hard as an attacker. Define limiters in the boot method of AppServiceProvider, as the Laravel rate limiting docs describe.

RateLimiter::for('api', function (Request $request) {
	if ($request->user()) {
		return Limit::perMinute(120)->by('user:'.$request->user()->id);
	}

	return Limit::perMinute(30)->by('ip:'.$request->ip());
});

RateLimiter::for('login', function (Request $request) {
	return Limit::perMinute(5)->by($request->input('email').$request->ip());
});

Signed-in users get a larger budget keyed to their ID, guests get a tighter one keyed to their IP, and the login limiter slows down password guessing. When a limit trips, Laravel returns a 429 with a Retry-After header, so build the app to back off instead of retrying instantly.

On Laravel 11 and newer, attach throttle:api yourself as the routes file above does, because the api group no longer applies it by default. Laravel 9 and 10 already apply it through the api middleware group, so drop it from the route group there.

Send push notifications from a queue

Push is where mobile backends meet queues. Don't call Firebase Cloud Messaging or APNs inside the request that books a class, because a slow push provider then becomes a slow API.

  1. Store a push token per user and device when the app registers it after login.

  2. Create a notification class that implements ShouldQueue, so dispatching it returns instantly.

  3. Deliver through an FCM channel, such as the community laravel-notification-channels/fcm package, from a queue worker.

  4. Delete push tokens the provider reports as invalid, so dead devices stop costing you sends.

Run the workers under Supervisor or Laravel Horizon, and restart them on every deploy. If you don't, they'll keep running stale code.

Test and deploy the Laravel mobile app backend

Old app builds depend on today's JSON, so contract tests are the cheapest insurance you can buy. A Pest test that asserts the response structure fails the moment someone renames a field inside v1.

use App\Models\Booking;
use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('keeps the v1 bookings contract stable', function () {
	$user = User::factory()->create();
	Booking::factory()->for($user)->count(3)->create();

	Sanctum::actingAs($user, ['bookings:read']);

	$this->getJson('/api/v1/bookings')
		->assertOk()
		->assertJsonStructure([
			'data' => ['*' => ['id', 'status', 'starts_at', 'created_at']],
			'meta' => ['next_cursor', 'per_page'],
		]);
});

Run it in CI on every pull request. When a change has to break the shape, the failing test points the change toward v2 instead of shipping it silently.

Deployment matters more for mobile than for web, because the app calls your API around the clock and nobody waits for a maintenance window. You want migrations that run automatically, queue workers that restart, and a rollback path when a release misbehaves. StackMivo's one-click deployment to Laravel Cloud, Forge, Ploi, or any SSH server covers those steps, with zero-downtime deploys and instant rollback.

If you'd prefer to start from a working backend instead of a blank folder, Get started free and generate a versioned bookings API with Sanctum auth, API Resources, and Pest tests as a production-ready Laravel app you can read, edit, and keep.

Where to start with your Laravel mobile app backend

A dependable Laravel mobile app backend comes down to respecting the clients you can't update. Put every route behind a version prefix before the first release, and treat each API Resource as a promise to the builds already in your users' pockets. Give every phone its own named, expiring Sanctum token so one lost device never forces a global logout.

From there, the details compound. Cursor pagination keeps infinite scroll honest, forced JSON errors keep the app's error handling simple, rate limits protect you from your own retry bugs, and queues keep push notifications off the request path. Contract tests and zero-downtime deploys make sure the next release doesn't undo any of it.

Start small this week. Pick one endpoint your app calls on launch, move it under /api/v1, wrap it in a Resource, and add a Pest test for its shape. Once that pattern exists, every new endpoint follows it, and your Laravel API for mobile app clients can grow for years without breaking the people who installed version one.