Back to blog

Creating APIs for Mobile Apps Using Laravel - Part II

Jul 10, 2024
.
Kristína Odziomková

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.


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:

  1. we are preparing an API that a 3rd party will consume - they usually prefer REST APIs
  2. 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:

  1. Caching: Unlike REST, GraphQL lacks built-in support for caching.
  2. 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.
  3. 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:

EventCategory attributes:

EventParticipant attributes:

<?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

GET event categories
GET event categories

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

GET paginated events
GET paginated events
GET event by ID
GET event by ID

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.

POST sign-up to event
POST sign-up to event
POST duplicate sign-up error
POST duplicate sign-up error

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 - paginated events
Query - paginated events

Query:

{
  event(eventId: 16) {
    id
      title {
        en
        es
      }
      description {
        en
        es
      }
      max_number_of_participants
      category {
        id
        title {
          en
          es
        }
      }
      date_time
  }
}
Query - event detail
Query - event detail

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 - sign up for event
Mutation - sign up for event

Mutation:

mutation {
  signOutFromEvent(eventId: 16) {
    id
  }
}
Mutation - sign out from event
Mutation - sign out from event

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.