P2 | REST vs. GraphQL API + more project actions
In the previous part of this blog series, we demonstrated how to quickly scaffold an API in Laravel with authentication via API tokens and examples of login, logout and user detail endpoints, both in REST and GraphQL.
In this part, we will dive deeper into the differences between REST and GraphQL API and craft more, business logic-oriented, endpoints.
- Laravel v11
- Laravel Sanctum v4
- Craftable PRO v2.0
- Lighthouse PHP v6
How do we decide when to use REST and when to use GraphQL for an API?
The biggest selling point for us is this:
"With REST APIs, endpoints are fixed and return predefined data structures. This can lead to over-fetching (receiving more data than needed) or under-fetching (not getting enough data in a single request). GraphQL allows clients to specify exactly what data they need, avoiding these issues by enabling clients to request only the fields they require, reducing network overhead and improving performance."
Therefore we use GraphQL for APIs in all the projects, whenever possible.
Unless:
- we are preparing an API that a 3rd party will consume - they usually prefer REST APIs
- the mobile app will be consuming data from multiple APIs, most of them REST - we don’t want to maintain both the REST and GraphQL API setup in the mobile app, if possible.
For GraphQL in Laravel we recommend this Composer package: Lighthouse
Of course GraphQL has some cons over REST API, such as:
- Caching: Unlike REST, GraphQL lacks built-in support for caching.
- Over-fetching and Under-fetching: Despite allowing clients to specify the exact data they need, poorly constructed queries in GraphQL can result in over-fetching (receiving more data than needed) or under-fetching (not obtaining all the required data), potentially affecting performance and efficiency.
- Complexity in Nested Data: The flexibility of GraphQL in fetching nested data may introduce complexities, especially when handling deeply nested structures, making query optimization for performance more challenging.
In the end, it is about finding the best compromise for your use case.
As a sample project for demonstration purposes, we have chosen to implement an event management system. The administrator has the capability to create, update, delete, and publish events (such as art exhibitions, concerts, etc.) using the admin panel. The mobile app client can query them and sign-up/sign-out of individual events.
The admin panel implementation with examples will be described in the next part of this blog series. In this part, we will look into preparing some basic API endpoints for the mobile app client.
Quick scaffold of the events model (15 minutes)
1. The first step is to create migration for the models.
Event attributes:
- translatable title - define this columns as jsonb
- translatable description - define this columns as jsonb.
- datetime date_time - when the event takes place
- integer max_number_of_participants - max. number of MobileAppUsers that can sign up to the event
- foreign key event_category_id - constrained to id of event_categories table
EventCategory attributes:
- translatable title - define this columns as jsonb
EventParticipant attributes:
- foreign key event_id - constrained to id of events table
- foreign key mobile_app_user_id - constrained to id of mobile_app_users table (created in part I.)
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('event_categories', function (Blueprint $table) {
$table->id();
$table->jsonb('title');
$table->timestamps();
});
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->jsonb('title');
$table->jsonb('description')->nullable();
$table->integer('max_number_of_participants')->nullable();
$table->foreignId('event_category_id')->constrained('event_categories');
$table->dateTime('date_time');
$table->timestamps();
$table->softDeletes();
});
Schema::create('event_participants', function (Blueprint $table) {
$table->id();
$table->foreignId('mobile_app_user_id')->constrained('mobile_app_users');
$table->foreignId('event_id')->constrained('events');
$table->timestamps();
});
}
};
2. Run migrations
sail artisan migrate
Create Eloquent models, controllers, seeders, factories, form requests, API resources and collections…
sail artisan make:model EventCategory -fsc --requests
sail artisan make:model Event -fsc --requests
sail artisan make:resource EventResource
sail artisan make:resource EventCategoryResource
sail artisan make:resource EventCollection
sail artisan make:resource EventCategoryCollection
-fsc creates factory, seeder, controller
If these command options are not supported for your version of Laravel, create the necessary files manually.
Now you should have this structure:
app/Models/EventCategory.php
app/Models/Event.php
database/factories/EventCategoryFactory.php
database/factories/EventFactory.php
database/seeders/EventCategorySeeder.php
database/seeders/EventSeeder.php
app/Http/Requests/StoreEventCategoryRequest.php
app/Http/Requests/UpdateEventCategoryRequest.php
app/Http/Requests/StoreEventRequest.php
app/Http/Requests/UpdateEventRequest.php
app/Http/Controllers/EventCategoryController.php
app/Http/Controllers/EventController.php
app/Http/Resources/EventCollection.php
app/Http/Resources/EventCategoryCollection.php
app/Http/Resources/EventResource.php
app/Http/Resources/EventCategoryResource.php
Filled in models:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EventCategory extends Model
{
use HasFactory;
protected $guarded = ['id'];
protected $casts = [
'title' => 'array',
];
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Event extends Model
{
use HasFactory;
protected $guarded = ['id'];
protected $casts = [
'title' => 'array',
'description' => 'array',
];
public function category(): BelongsTo
{
return $this->belongsTo(EventCategory::class, 'event_category_id');
}
public function participants(): BelongsToMany
{
return $this->belongsToMany(
MobileAppUser::class,
'event_participants',
'event_id',
'mobile_app_user_id'
)->withTimestamps();
}
}
Factories:
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EventCategory>
*/
class EventCategoryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition(): array
{
return [
'title' => [
'en' => $this->faker->word(),
'es' => $this->faker->word(),
]
];
}
}
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
*/
class EventFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition(): array
{
return [
'title' => [
'en' => $this->faker->words(2),
'es' => $this->faker->words(2),
],
'description' => [
'en' => $this->faker->paragraph(),
'es' => $this->faker->paragraph(),
],
'max_number_of_participants' => $this->faker->numberBetween(1, 20),
'date_time' => $this->faker->dateTimeBetween('now', '+1 year'),
];
}
}
Seeders:
<?php
namespace Database\Seeders;
use App\Models\EventCategory;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class EventCategorySeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
EventCategory::factory()->count(5)->create();
}
}
<?php
namespace Database\Seeders;
use App\Models\Event;
use App\Models\EventCategory;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class EventSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
Event::factory()->count(5)->create([
'event_category_id' => EventCategory::all()->random()->id,
]);
Event::factory()->count(5)->create([
'event_category_id' => EventCategory::all()->random()->id,
]);
Event::factory()->count(5)->create([
'event_category_id' => EventCategory::all()->random()->id,
]);
}
}
To seed event categories and events, run:
sail composer dump-autoload
sail artisan db:seed --class=EventCategorySeeder
sail artisan db:seed --class=EventSeeder
In the next section we will fill out the routes, controllers, form requests and API resources while implementing the API endpoints.
GraphQL/REST API for events (1 - 2 hours)
In this section, we will create endpoints for querying paginated event listings and accessing detailed information about a single event. Additionally, we will implement functionality for signing up to and signing out of events. These implementations will be demonstrated using both REST and GraphQL for illustration.
If you followed the part I. of this blog series, you should already have a routes/api.php file (for REST API) and/or graphql/schema.graphql file (for GraphQL API) and authentication implementation.
REST API
1. Querying data
routes/api.php
Route::middleware('auth:mobile-app')->group(function () {
Route::get('event-categories', 'App\Http\Controllers\EventCategoryController@get')->name('event-category.get');
Route::get('events', 'App\Http\Controllers\EventController@paginated')->name('events.paginated');
Route::get('events/{event}', 'App\Http\Controllers\EventController@detail')->name('events.detail');
});
App\Http\Controllers\EventCategoryController.php
<?php
namespace App\Http\Controllers;
use App\Http\Resources\EventCategoryCollection;
use App\Models\EventCategory;
class EventCategoryController extends Controller
{
public function get(): EventCategoryCollection
{
return new EventCategoryCollection(EventCategory::all());
}
}
App\Http\Resources\EventCategoryCollection.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class EventCategoryCollection extends ResourceCollection
{
public $collects = EventCategoryResource::class;
}
App\Http\Resources\EventCategoryResource.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventCategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
];
}
}
Postman examples
Don’t forget to send the Accept: application/json header, along with the API token as Bearer Token type of authorization
App\Http\Controllers\EventController.php
<?php
namespace App\Http\Controllers;
use App\Http\Resources\EventCollection;
use App\Http\Resources\EventResource;
use App\Models\Event;
use Illuminate\Http\Request;
class EventController extends Controller
{
public function get(): EventCategoryCollection
{
return new EventCategoryCollection(EventCategory::all());
}
public function detail(Request $request, Event $event): EventResource
{
return new EventResource($event);
}
}
App\Http\Resources\EventCollection.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class EventCollection extends ResourceCollection
{
public $collects = EventResource::class;
}
App\Http\Resources\EventResource.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'max_number_of_participants' => $this->max_number_of_participants,
'event_category' => new EventCategoryResource($this->eventCategory),
'date_time' => $this->date_time
];
}
}
Postman examples
Don’t forget to send the Accept: application/json header, along with the API token as Bearer Token type of authorization
2. Mutating data
routes/api.php
Route::middleware('auth:mobile-app')->group(function () {
Route::post('events/{event}/signup', 'App\Http\Controllers\EventController@signup')->name('events.signup');
Route::post('events/{event}/signout', 'App\Http\Controllers\EventController@signout')->name('events.signout');
});
App\Http\Controllers\EventController.php
<?php
namespace App\Http\Controllers;
use App\Http\Resources\EventCategoryCollection;
use App\Models\EventCategory;
class EventCategoryController extends Controller
{
...
public function signup(Request $request, Event $event): JsonResponse
{
$user = Auth::guard('mobile-app')->user();
if($event->participants()->where('mobile_app_users.id', $user->id)->exists()) {
return response()->json(['message' => 'Already participated'], 400);
}
if($event->participants()->count() >= $event->max_number_of_participants) {
return response()->json(['message' => 'Max. number of participants reached'], 400);
}
$event->participants()->attach($user->id);
return response()->json(new EventResource($event));
}
public function signout(Request $request, Event $event): JsonResponse
{
$user = Auth::guard('mobile-app')->user();
$event->participants()->detach($user->id);
return response()->json(new EventResource($event));
}
}
Postman examples
Don’t forget to send the Accept: application/json header, along with the API token as Bearer Token type of authorization.
GraphQL API
1. Querying data
graphql/schema.graphql
type LocalizedString {
en: String
es: String
}
type Event @model(class: "App\\Models\\Event"){
id: ID!
title: LocalizedString!
description: LocalizedString!
max_number_of_participants: Int!
category: EventCategory! @belongsTo
date_time: DateTime!
}
type EventCategory {
id: ID!
title: LocalizedString!
}
extend type Query @guard {
events: [Event!]! @paginate(resolver: "App\\GraphQL\\Queries\\Events")
event(eventId: ID!): Event!
}}
App\GraphQL\Queries\Events.php
sail artisan lighthouse:query Events
<?php
namespace App\GraphQL\Queries;
use App\Models\Event;
final readonly class Events
{
/** @param array{} $args */
public function __invoke(null $_, array $args)
{
return Event::query()
->with('category')
->orderByDesc('date_time')
->paginate($args['first'] ?? 10, ['*'], 'page', $args['page'] ?? 1);
}
}
App\GraphQL\Queries\Event.php
sail artisan lighthouse:query Event
<?php
namespace App\GraphQL\Queries;
use App\Http\Resources\EventResource;
final readonly class Event
{
/** @param array{} $args */
public function __invoke(null $_, array $args)
{
return \App\Models\Event::findOrFail($args['eventId']);
}
}
GraphiQL examples (http://localhost/graphiql)
Headers:
{
"Authorization": "Bearer your_token"
}
Query:
{
events(first: 1, page: 1) {
data {
id
title {
en
es
}
description {
en
es
}
max_number_of_participants
category {
id
title {
en
es
}
}
date_time
}
paginatorInfo {
currentPage
total
}
}
}
Query:
{
event(eventId: 16) {
id
title {
en
es
}
description {
en
es
}
max_number_of_participants
category {
id
title {
en
es
}
}
date_time
}
}
2. Mutating data
graphql/schema.graphql
type Mutation {
signUpForEvent(
eventId: ID!
): Event! @guard
signOutFromEvent(
eventId: ID!
): Event! @guard
}
App\GraphQL\Mutations\SignUpForEvent.php
sail artisan lighthouse:mutation SignUpForEvent
<?php
namespace App\GraphQL\Mutations;
use App\Exceptions\GraphQLException;
use App\Models\Event;
use Illuminate\Support\Facades\Auth;
final readonly class SignUpForEvent
{
/**
* @throws GraphQLException
*/
public function __invoke(null $_, array $args)
{
$user = Auth::guard('mobile-app')->user();
$event = Event::findOrFail($args['eventId']);
if($event->participants()->where('mobile_app_users.id', $user->id)->exists()) {
throw new GraphQLException('Already participated', '400'); // <-- error handling explained in the next blog post
}
if($event->participants()->count() >= $event->max_number_of_participants) {
throw new GraphQLException('Max. number of participants reached', '400'); // <-- error handling explained in the next blog post
}
$event->participants()->attach($user->id);
return $event;
}
}
App\GraphQL\Mutations\SignOutFromEvent.php
sail artisan lighthouse:mutation SignOutFromEvent
<?php
namespace App\GraphQL\Mutations;
use App\Models\Event;
use Illuminate\Support\Facades\Auth;
final readonly class SignOutFromEvent
{
public function __invoke(null $_, array $args)
{
$user = Auth::guard('mobile-app')->user();
$event = Event::findOrFail($args['eventId']);
$event->participants()->detach($user->id);
return $event;
}
}
GraphiQL examples (http://localhost/graphiql)
Headers:
{
"Authorization": "Bearer your_token"
}
Mutation:
mutation {
signUpForEvent(eventId: 16) {
id
}
}
Mutation:
mutation {
signOutFromEvent(eventId: 16) {
id
}
}
What’s next
In the next part of this series we will look into how to handle errors for both REST and GraphQL APIs.
Conclusion
When deciding between using REST and GraphQL to build an API, it may appear challenging. Ultimately, the key is to find the optimal compromise for your specific use case, considering that each approach comes with its own set of advantages and disadvantages.
Adding new routes/queries is pretty straightforward after learning how to do it once. Manual testing using Postman or GraphiQL is an important step, but in the future blog posts we will also cover automated testing for your APIs.