Laravel API: Blade Meets JSON

SS Saurav Sitaula

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:

  1. Build a separate API application
  2. 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

  1. API Resources are essential. Never return raw Eloquent models. Control your output.

  2. Separate web and API controllers. Even if they share a service, the response handling is different.

  3. API errors must be JSON. Check $request->expectsJson() in your exception handler.

  4. Version from day one. Adding /v1/ later is painful. Start with it.

  5. 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.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism