Courses

Laravel Web to Mobile API: Reuse Old Code with Services

Admin API Routes

This course is divided into two sections. In the first part, we will take one function (managing restaurants by administrators) and perform the full refactoring for mobile API:

  • Create API Controllers/Routes/Resources
  • Create a Service Class and reuse it in both web and API
  • Write automated tests for both web and API to ensure both parts work

Then, in the second section of the course, we will just keep practicing the same routine on other CRUDs and Models: categories, products, shopping cart, etc.

Reminder: This course is a follow-up on a previous course Laravel Vue Inertia: Food Ordering Project Step-By-Step so you can look at the functionality and take the repository from there, as a starting point of our upcoming refactoring.

In this lesson, we will make the API for our application. Let's start with restaurant functionality for admin users.


Setup API routing

Sanctum offers a simple way to authenticate single-page applications (SPAs) that need to communicate with a Laravel-powered API.

Add Sanctum's middleware to your api middleware group within your application's app/Http/Kernel.php file to enable first-party API authentication.

app/Http/Kernel.php

'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],

We want to introduce API versioning from the beginning, so URLs have a /api/v1 prefix. To do that, update the prefix() in RouteServiceProvider for api middleware.

In addition, it is helpful to have the api. prefix on all our API routes. At first glance, it might seem unnecessary, but it helps a lot in writing tests, so we do not have to hardcode URLs. Another advantage is if you use Ziggy library in your client. It allows you to use Laravel routes in JavaScript. Paths are prefixed using the as('api.') method.

app/Providers/RouteServiceProvider.php

$this->routes(function () {
Route::middleware('api')
->prefix('api')
->prefix('api/v1')
->as('api.')
->group(base_path('routes/api.php'));
 
Route::middleware('web')

Create API Resources

When building an API, you may need a transformation layer between your Eloquent models and the JSON responses returned to your application's users.

To generate a resource class, you may use the make:resource Artisan command. By default, resources will be placed in your application's app/Http/Resources directory.

php artisan make:resource Api/V1/Admin/RestaurantResource

In addition to generating resources that transform individual models, you may create resources responsible for transforming models' collections.

To create a resource collection, you should use the --collection flag when creating the resource. Or, including the word Collection in the resource name will indicate to Laravel that it should create a collection resource.

php artisan make:resource Api/V1/Admin/RestaurantCollection

Create API Admin RestaurantController

API Controllers are created using the same make:controller command. We organize them by prefixing them with Api/V1/Admin/. As we have versioned routes respectively, we have the Controller for specific API versions.

php artisan make:controller Api/V1/Admin/RestaurantController

app/Http/Controllers/Api/V1/Admin/RestaurantController.php

namespace App\Http\Controllers\Api\V1\Admin;
 
use App\Enums\RoleName;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreRestaurantRequest;
use App\Http\Requests\Admin\UpdateRestaurantRequest;
use App\Http\Resources\Api\V1\Admin\RestaurantCollection;
use App\Http\Resources\Api\V1\Admin\RestaurantResource;
use App\Models\Restaurant;
use App\Models\Role;
use App\Models\User;
use App\Notifications\RestaurantOwnerInvitation;
use App\Services\RestaurantService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
 
class RestaurantController extends Controller
{
public function index(): RestaurantCollection
{
$this->authorize('restaurant.viewAny');
 
$restaurants = Restaurant::with(['city', 'owner'])->get();
 
return new RestaurantCollection($restaurants);
}
 
public function store(StoreRestaurantRequest $request): RestaurantResource
{
$validated = $request->validated();
 
$restaurant = DB::transaction(function () use ($validated) {
$user = User::create([
'name' => $validated['owner_name'],
'email' => $validated['email'],
'password' => '',
]);
 
$user->roles()->sync(Role::where('name', RoleName::VENDOR->value)->first());
 
$user->restaurant()->create([
'city_id' => $validated['city_id'],
'name' => $validated['restaurant_name'],
'address' => $validated['address'],
]);
 
$user->notify(new RestaurantOwnerInvitation($validated['restaurant_name']));
});
 
return new RestaurantResource($restaurant);
}
 
public function show(Restaurant $restaurant): RestaurantResource
{
$this->authorize('restaurant.view');
 
$restaurant->load(['city', 'owner']);
 
return new RestaurantResource($restaurant);
}
 
public function update(UpdateRestaurantRequest $request, Restaurant $restaurant): JsonResponse
{
$validated = $request->validated();
 
$restaurant->update([
'city_id' => $validated['city_id'],
'name' => $validated['restaurant_name'],
'address' => $validated['address'],
]);
 
return (new RestaurantResource($restaurant))
->response()
->setStatusCode(Response::HTTP_ACCEPTED);
}
}

Let's discuss the RestaurantController methods.

The index() method returns API resource collection RestaurantCollection($restaurants). It returns everything available in the $restaurants collection by default.

app/Http/Resources/Api/V1/Admin/RestaurantCollection.php

namespace App\Http\Resources\Api\V1\Admin;
 
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
 
class RestaurantCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

The collection will be cast into JSON when returning a response.

{
"data": [
{
"id": 1,
"owner_id": 2,
"city_id": 260,
"name": "Restaurant 001",
"address": "Address SJV14",
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:56:03.000000Z",
"city": {
"id": 260,
"name": "Tbilisi",
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:12:10.000000Z"
},
"owner": {
"id": 2,
"restaurant_id": null,
"name": "Restaurant owner",
"email": "vendor@admin.com",
"email_verified_at": null,
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:12:10.000000Z"
}
},
// ...
]
}

We can transform the JSON response of the collection by modifying the RestaurantCollection file. Let's say we want to add metadata to the response, so we can define that like this.

app/Http/Resources/Api/V1/Admin/RestaurantCollection.php

namespace App\Http\Resources\Api\V1\Admin;
 
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
 
class RestaurantCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'links' => [
'self' => 'link-value',
],
];
}
}

And new data will be appended in the response.

{
"data": [
{
"id": 1,
"owner_id": 2,
"city_id": 260,
"name": "Restaurant 001",
"address": "Address SJV14",
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:56:03.000000Z",
"city": {
"id": 260,
"name": "Tbilisi",
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:12:10.000000Z"
},
"owner": {
"id": 2,
"restaurant_id": null,
"name": "Restaurant owner",
"email": "vendor@admin.com",
"email_verified_at": null,
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:12:10.000000Z"
}
},
// ...
],
"links": {
"self": "link-value"
}
}

The show method returns a resource containing all model attributes except $hidden ones by default.

app/Http/Resources/Api/V1/Admin/RestaurantResource.php

namespace App\Http\Resources\Api\V1\Admin;
 
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
 
class RestaurantResource extends JsonResource
{
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

Example response:

{
"data": {
"id": 2,
"owner_id": 4,
"city_id": 22,
"name": "Barton LLC",
"address": "182 Bailey Island\nPort Jasen, TX 12669-3553",
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:12:10.000000Z",
"city": {
"id": 22,
"name": "Bălți",
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:12:10.000000Z"
},
"owner": {
"id": 4,
"restaurant_id": null,
"name": "Cecile Pfeffer",
"email": "ftorp@example.com",
"email_verified_at": "2023-07-25T15:12:10.000000Z",
"created_at": "2023-07-25T15:12:10.000000Z",
"updated_at": "2023-07-25T15:12:10.000000Z"
}
}
}

We can control what model attributes are returned. If we specify the id and name fields, only these fields appear in a response.

app/Http/Resources/Api/V1/Admin/RestaurantResource.php

namespace App\Http\Resources\Api\V1\Admin;
 
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
 
class RestaurantResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}

Example response:

{
"data": {
"id": 2,
"name": "Barton LLC"
}
}

And this will apply even to collections:

{
"data": [
{
"id": 1,
"name": "Restaurant 001"
},
{
"id": 2,
"name": "Barton LLC"
},
// ...
]
}

The ExampleCollection class checks if an ExampleResource class is defined. If such a class exists, it will transform every Model in the collection by the rules of ExampleResource.

The store method will return a response with the status 201 Created because the newly created Model has the wasRecentlyCreated attribute set to true, so we do not need to handle that manually.

By default, the update method will return a 200 Ok response. Without dubious hacks, there is no magical way to know if the Model was recently updated, so we manually set the status code to 202 Accepted.

public function update(...): JsonResponse
{
...
 
return (new RestaurantResource($restaurant))
->response()
->setStatusCode(Response::HTTP_ACCEPTED);
}

Because of that, returned type is JsonResponse instead of RestaurantResource, but this doesn't make much of a difference because RestaurantResource extends JsonResponse.


Add Routes for RestaurantController

When declaring resource routes consumed by APIs, you commonly want to exclude routes with HTML templates such as create and edit. For convenience, you may use the apiResource method to exclude these two routes automatically.

Create the new admin.php file for API routes.

routes/api/v1/admin.php

use App\Http\Controllers\Api\V1\Admin\RestaurantController;
use Illuminate\Support\Facades\Route;
 
Route::group([
'prefix' => 'admin',
'as' => 'admin.',
'middleware' => ['auth'],
], function () {
Route::apiResource('restaurants', RestaurantController::class);
});

And finally, include routes/api/v1/admin.php in the main api.php file.

routes/api.php

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
 
include __DIR__ . '/api/v1/admin.php';

No comments or questions yet...