Laravel API: Blade Meets JSON
My app had beautiful Blade templates. But mobile developers needed JSON, not HTML. Learn to build REST APIs in Laravel alongside your web routes, handle API authentication, transform responses, and serve both worlds from one codebase.
The Mobile Developer Request
My Laravel app was humming along. Blade templates, authentication, email and SMS notifications. Beautiful HTML pages.
Then the mobile team asked: “Can we get an API?”
“An API? But… I have web pages.”
“We need JSON. Not HTML.”
I had two choices:
- Build a separate API application
- Add API endpoints to my existing Laravel app
Laravel made option 2 surprisingly easy.
Web Routes vs API Routes
Laravel separates routes by default:
routes/
├── web.php # HTML responses, sessions, CSRF
└── api.php # JSON responses, stateless, tokens
web.php (existing):
Route::get('/users', 'UserController@index'); // Returns Blade view
Route::post('/users', 'UserController@store'); // Redirects after creation
api.php (new):
Route::get('/users', 'Api\UserController@index'); // Returns JSON
Route::post('/users', 'Api\UserController@store'); // Returns JSON
API routes automatically get the /api prefix. So /users in api.php becomes /api/users.
Creating an API Controller
php artisan make:controller Api/UserController --api
The --api flag creates a controller without create and edit methods (those are for HTML forms, not APIs).
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index()
{
$users = User::all();
return response()->json($users);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => bcrypt($validated['password']),
]);
return response()->json($user, 201);
}
public function show($id)
{
$user = User::findOrFail($id);
return response()->json($user);
}
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $id,
]);
$user->update($validated);
return response()->json($user);
}
public function destroy($id)
{
$user = User::findOrFail($id);
$user->delete();
return response()->json(null, 204);
}
}
Proper HTTP status codes:
- 200: Success (GET, PUT)
- 201: Created (POST)
- 204: No Content (DELETE)
- 404: Not Found
- 422: Validation Error
API Resources: Controlling Output
Returning User::all() directly is dangerous. It might expose passwords, internal IDs, or sensitive data.
API Resources transform your models:
php artisan make:resource UserResource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'avatar_url' => $this->avatar_url,
'created_at' => $this->created_at->toISOString(),
// password is NOT included
// internal flags are NOT included
];
}
}
Use it:
use App\Http\Resources\UserResource;
public function show($id)
{
$user = User::findOrFail($id);
return new UserResource($user);
}
public function index()
{
$users = User::paginate(15);
return UserResource::collection($users);
}
Output:
{
"data": {
"id": 1,
"name": "Saurav",
"email": "saurav@example.com",
"avatar_url": "https://...",
"created_at": "2019-06-28T10:30:00.000Z"
}
}
For collections with pagination:
{
"data": [
{ "id": 1, "name": "Saurav", ... },
{ "id": 2, "name": "John", ... }
],
"links": {
"first": "http://myapp.com/api/users?page=1",
"last": "http://myapp.com/api/users?page=5",
"prev": null,
"next": "http://myapp.com/api/users?page=2"
},
"meta": {
"current_page": 1,
"total": 73,
"per_page": 15
}
}
Conditional Attributes
Sometimes you want to include extra data only in certain contexts:
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// Only include if relationship is loaded
'posts' => PostResource::collection($this->whenLoaded('posts')),
// Only include for authenticated user viewing their own profile
'phone' => $this->when($request->user()->id === $this->id, $this->phone),
// Include count if it's loaded
'posts_count' => $this->when(isset($this->posts_count), $this->posts_count),
];
}
API Authentication
Web routes use sessions. APIs need tokens.
Option 1: Laravel Passport (OAuth2)
Full OAuth2 implementation. Great for third-party apps.
composer require laravel/passport
php artisan migrate
php artisan passport:install
Option 2: Simple API Tokens (Laravel 5.6 style)
For simple first-party APIs, token authentication works:
// Migration
$table->string('api_token', 80)->unique()->nullable();
// User model
public function generateApiToken()
{
$this->api_token = Str::random(60);
$this->save();
return $this->api_token;
}
// Login endpoint
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($credentials)) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
$user = Auth::user();
$token = $user->generateApiToken();
return response()->json([
'user' => new UserResource($user),
'token' => $token,
]);
}
Protect routes:
// routes/api.php
Route::middleware('auth:api')->group(function () {
Route::get('/user', function (Request $request) {
return new UserResource($request->user());
});
Route::apiResource('posts', 'Api\PostController');
});
Clients send the token:
GET /api/user
Authorization: Bearer your-token-here
Or as a query parameter:
GET /api/user?api_token=your-token-here
Error Handling for APIs
Blade apps show error pages. APIs need JSON errors.
Create app/Exceptions/Handler.php modifications:
use Illuminate\Http\Request;
public function render($request, Exception $exception)
{
if ($request->expectsJson()) {
return $this->handleApiException($request, $exception);
}
return parent::render($request, $exception);
}
private function handleApiException($request, Exception $exception)
{
if ($exception instanceof ModelNotFoundException) {
return response()->json([
'error' => 'Resource not found'
], 404);
}
if ($exception instanceof ValidationException) {
return response()->json([
'error' => 'Validation failed',
'messages' => $exception->errors()
], 422);
}
if ($exception instanceof AuthenticationException) {
return response()->json([
'error' => 'Unauthenticated'
], 401);
}
// Don't expose internal errors in production
if (config('app.debug')) {
return response()->json([
'error' => $exception->getMessage(),
'trace' => $exception->getTrace()
], 500);
}
return response()->json([
'error' => 'Server error'
], 500);
}
Now API requests get JSON errors:
{
"error": "Validation failed",
"messages": {
"email": ["The email field is required."],
"name": ["The name must be at least 2 characters."]
}
}
Sharing Logic: Services
My web controller and API controller were duplicating logic. Solution: service classes.
// app/Services/UserService.php
<?php
namespace App\Services;
use App\User;
class UserService
{
public function create(array $data): User
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
}
public function update(User $user, array $data): User
{
$user->update($data);
return $user;
}
}
Web controller:
class UserController extends Controller
{
protected $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function store(Request $request)
{
$validated = $request->validate([...]);
$user = $this->userService->create($validated);
return redirect('/users')->with('success', 'User created!');
}
}
API controller:
class UserController extends Controller
{
protected $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function store(Request $request)
{
$validated = $request->validate([...]);
$user = $this->userService->create($validated);
return new UserResource($user);
}
}
Same business logic, different response formats.
Rate Limiting
APIs need protection from abuse:
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
'throttle:60,1', // 60 requests per minute
// ...
],
];
Custom limits for authenticated users:
// routes/api.php
Route::middleware('throttle:1000,1')->group(function () {
// High-limit routes for authenticated users
});
Route::middleware('throttle:10,1')->group(function () {
// Low-limit for login attempts
Route::post('/login', 'Api\AuthController@login');
});
API Versioning
When you need to make breaking changes:
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('users', 'Api\V1\UserController');
});
Route::prefix('v2')->group(function () {
Route::apiResource('users', 'Api\V2\UserController');
});
URLs become /api/v1/users and /api/v2/users.
CORS for Frontend Apps
React apps on different domains need CORS:
// app/Http/Middleware/Cors.php
<?php
namespace App\Http\Middleware;
use Closure;
class Cors
{
public function handle($request, Closure $next)
{
return $next($request)
->header('Access-Control-Allow-Origin', '*')
->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
}
Register in Kernel.php:
protected $middlewareGroups = [
'api' => [
\App\Http\Middleware\Cors::class,
// ...
],
];
In Laravel 7+, there’s a built-in CORS package. In 5.6, we did it manually.
Testing the API
# GET request
curl http://localhost:8000/api/users
# POST with JSON
curl -X POST http://localhost:8000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com","password":"secret123"}'
# Authenticated request
curl http://localhost:8000/api/user \
-H "Authorization: Bearer your-token-here"
Or use Postman. Much nicer.
What I Wish I’d Known Earlier
-
API Resources are essential. Never return raw Eloquent models. Control your output.
-
Separate web and API controllers. Even if they share a service, the response handling is different.
-
API errors must be JSON. Check
$request->expectsJson()in your exception handler. -
Version from day one. Adding
/v1/later is painful. Start with it. -
Use Postman or Insomnia. Testing APIs with curl gets old fast.
The Journey Continues
My Laravel app now served two masters: Blade templates for the web, JSON for mobile and React frontends.
But running both locally was getting complicated. Multiple configurations, different environments, deployment headaches.
Time to think about proper development workflows and deployment strategies.
P.S. — The day my React frontend successfully fetched data from my Laravel API, I felt like I’d built a real full-stack application. Two codebases, talking to each other, doing useful things. That’s the moment web development clicked for me.
Saurav Sitaula
Software Architect • Nepal