Skip to main content

Defining and fine tuning an API in Laravel with the Stoplight Platform

Published on

Stoplight Platform is a series of tools that makes API design and documentation super easy. Their Studio product is an OpenAPI editor. Prism allows you to create a mock HTTP server to generate sample data based on your design specification. The Elements project creates documentation from your OpenAPI specification file. At Bluehorn Digital, we used Stoplight Studio to fine-tune an API provided by a Laravel (Lumen, actually) application.

I want to share how we used Stoplight Studio to customize the default Laravel model serialization API. I used Studio to create the API design and specify adjustments needed to the Laravel models (casts, date formats) and required fields in our controller request validation when creating new models. Studio also allowed us to provide documentation that could be handed off to the consumer! The consumer was also able to begin writing their integration application using the HTTP mock server provided by Prism. 💥

Note you don't need to use Stoplight's products or tools. You could use the Swagger Editor or any other OpenAPI tools. I like the Spotlight Platform the most.

This could be considered "backward," in that we didn't design the API first in an OpenAPI specification and then write the server code. However, life isn't always perfect. In our case, we had existing Laravel models and an API that we needed to expose to a new third-party consumer, where the API was entirely consumed by our own frontend. The following process allowed us to audit model data and design a proper API for third-party consumers.

The Laravel application

Let's start with a basic Laravel application and a model that will be consumed over the API.

The model, migration, factory, and controller setup

I will use a basic example using a model for a Reservation at a hotel, inn, cabin, or other.

composer create-project laravel/laravel openapi-app
cd openapi-app

Now that the Laravel application is created, we can generate a model. We will create a migration and factory. Factories allow us to create sample data for our models. We will use the factory, so we have some basic JSON schema to import into Stoplight Studio.

# Shortcut to generate a model, migration, factory, seeder, and controller...
php artisan make:model Reservation --all

Now that the model and its migration, seeder, and controller are generated, we can create its schema. This is done by editing the migration added in database/migrations. Mine was 2021_09_18_005952_create_reservations_table.php, which yours will be different due to the date!

We'll add some primary fields.

  • The reservation date (we'll pretend check-in is always at 2 PM, so the time doesn't matter.)
  • A boolean to mark if the reservation was confirmed
  • A requests and admin notes text field
  • A reference to the user who owns the reservation and who approved it.
<?php

use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateReservationsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('reservations', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->date('reservation_date');
            $table->boolean('confirmed')->default(false);
            $table->text('requests')->nullable();
            $table->text('admin_notes')->nullable();
            $table->foreignId('approved_by')
                ->references('id')
                ->on('users');
            $table->foreignId('reserved_for')
                ->references('id')
                ->on('users');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('reservations');
    }
}

Next, we need to edit the model so that the Eloquent ORM understands the relationships we desire on approved_by and reserved_for. Edit app/Models/Reservation.php and add the following methods so that the ORM understands our desired relationships to the User model.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Reservation extends Model
{
    use HasFactory;

    public function approved_by()
    {
        return $this->belongsTo(User::class, 'approved_by', 'id');
    }

    public function reserved_for()
    {
        return $this->belongsTo(User::class, 'reserved_for', 'id');
    }

}

Now that we have the schema and model relationships defined, let's update the factory to generate models with sweet sample data. Edit the database/factories/ReservationFactory.php that was generated. We need to update the definition to use some random data from the Faker library. We will also randomly load a user and add it as the reference for our reservation owner and approval user.

<?php

namespace Database\Factories;

use App\Models\Reservation;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class ReservationFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Reservation::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'reservation_date' => $this->faker->iso8601(),
            'confirmed' => $this->faker->boolean(),
            'requests' => $this->faker->text(),
            'admin_notes' => $this->faker->text(),
            'approved_by' => User::inRandomOrder()->take(1)->first()->id,
            'reserved_for' => User::inRandomOrder()->take(1)->first()->id,
        ];
    }
}

Then, let's update the database seeder database/seeders/DatabaseSeeder.php so we get some generated models. Note! We need to import users first since our reservation factory randomly selects user relationships.

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        \App\Models\User::factory(4)->create();
        \App\Models\Reservation::factory(2)->create();
    }
}

For testing, we'll generate four different users and two reservations. That's more than enough to create a sample schema that we will import into Stoplight Studio for fine-tuning.

Next, we need to make a route and update the controller to retrieve our models in a serialized JSON format. Edit routes/api.php and add the following entry after the default code.

Route::get('/reservations', [App\Http\Controllers\ReservationController::class, 'index']);

Next, we need to fetch models in our ReservationController::index method. To keep things simple, we're just going to load all reservations with all relations so we can get a big picture. (Hint: we would also want an admin-based endpoint for editing and one for customers to only view.) Pop open app/Http/Controllers/ReservationController.php and give it a good ole update like the following.

<?php

namespace App\Http\Controllers;

use App\Models\Reservation;
use Illuminate\Http\Request;

class ReservationController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        return Reservation::with('approved_by')->with('reserved_for')->get();
    }
}

Okay! LET'S SEE SOME JSON!

First. Let's get Laravel to use SQLite to avoid needing a MySQL database. Edit your .env and update the DB_* variables to:

DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=

One more quick gotcha. Laravel will not automatically make the SQLite database file if it is missing. So create an empty file named database.sqlite in the database directory.

touch database/database.sqlite

Now that we use SQLite and have a database connection run the following to populate the database schema and sample data.

php artisan migrate --seed

Now fire up the built-in PHP server and ping that endpoint!

php artisan serve

Go to http://127.0.0.1:8000/api/reservations in your browser, or with cURL, and you should see some data!

Making an API you won't hate with Stoplight Studio

Sorry, Phil Sturgeon. I couldn't resist putting a twist on APIs You Won't Hate.

Head over to the Stoplight Studio page and get started with their online Workspace (requires an account) or download a desktop app (account optional, local storage available.) I use the desktop app connected to a workspace.

 

 

Once you have the app opened, create a new local project. I am going to call it Reservation API in this example.

Once we have our project, let's add a new API inside of the project. This is where we will begin using the user interface to generate our OpenAPI schema. Click on the API button in the sidebar to get started and give the API a name. Your API contains all of the various endpoints. If you're used to microservices architecture, each API would be its microservice.

Out of the box, new APIs have a default User model and paths for getting a user, adding a user, and updating a user. My first step is to delete these things usually. As far as I know, there is no way to turn this off. For this post, ignore them or go ahead and delete them.

We're going to add a new model for our reservation model in Laravel and then an endpoint that matches the path we accessed before – /api/reservations. To add new items to your project, click the blue plus button.

So, let's add our model! Click the add plus button, and then model. Name the model "Reservation" and set the scope to Single API, then press create. You can choose the Common option under Scope. Use this if you have APIs that the same models when designing your actual APIs.

Now that we have a model, you will see the Generate from JSON button to create the model's schema from an existing JSON blob, which we happen to have! That's why we started the Laravel model first, so we had a baseline we could transform and improve. Click on Generate from JSON and paste the data from a Reservation model.

Next, we click on Generate. And, boom! We now have a model schema that we can tweak, adjust, and verify.

Okay. You might be thinking: Is this necessary? I could refresh the http://127.0.0.1:8000/api/reservations endpoint and keep changing my Laravel model as I specify casts, what should be hidden, etc. This is a true statement and completely valid. But. BUT. Is your API documented this way? Is there an architecture document? Are there notes? Can this be shared and mocked without an actual backend server available? At first glance, this may seem like overkill, but from my experience, it is not. We have all been on projects where we look at the code and wonder, "why in the world does this do that?!" Well, the API design document explains it all and is the contract and source of truth.

I also like this approach as it feels easier to audit the fields returned by the API and see what should be removed or restricted. For example, we do not need to know when the reference users were created, updated, had their email verified, or their email. We probably need their name field and identifier (for links.)

When you hover over a field on a model, a few icons pop up. You can move a property up or down in order, duplicate it, or delete it.

Let's delete the created_at, updated_at, email, and email_verified_at from the approved_by and reserved_for properties.

Now that we've removed frivolous fields let's review what is left. Notice that everything is a string. The next step is to identify the different schemas for each property correctly.

  • created_at, updated_at: it's a string, but it's a date and time string
  • reservation_date: it's a string, but it's only a date (remember, we decided check-in is always at 2 PM, we don't care about the time.)
  • confirmed: this is not a string; it's a boolean! But, that's because all values are treated as strings when retrieved from the database.

First, let's address the format of the date strings. This can be done by specifying properties on these fields. We can open the properties for each field and specify the format. The row icon on the right is for Other properties.

Clicking that icon will open a dialog that lets us specify a format for our string fields. The OpenAPI specification has many string field types available.

To change a field's schema, you click on the current schema type to open a dialog. Make sure you unselect the current schema type! OpenAPI allows for unions – string OR number. So a field can be of multiple types.

 

Woo! We've done it! We triaged our model's schema!

Next, we need to create an endpoint in Stoplight that represents the Laravel route we have already defined. Click the add plus button, and then Endpoint. Set the path to /api/reservation, just like we have it in Laravel. Click on GET to toggle the HTTP method. We are only going to define one HTTP method which allows fetching the data. Press Create to add the endpoint.

With the endpoint created, we need to specify the response. Click + Response to allow specifying a response for the endpoint. This will create a default HTTP 200 response. Click on + Add Body for the response, which will enable us to specify that it returns an array of Reservation models.

 

The default body response is a single object. Our endpoint currently returns an array of models. Click on object to change the schema to array. When an array is chosen, we can specify a subtype. Choose $ref for the subtype so that we can reference our Reservation model.

And we have now defined our endpoint! We can mock the response and see if we're satisfied before updating our Laravel model.

Previewing our data using Prism

The Prism HTTP server mocking tool is built into Stoplight Studio. All we have to do is click Mocks and then the API endpoint we want to mock, and we'll get the output of sample data!

You'll notice when accessing the mocks that we could do more fine-tuning of our API specification. That's a bit outside the scope of this post. But, it shows why this step is essential! For example, we know that identifiers will not be floats or negative. These are properties that can be set on each model field. I know this may seem trivial, but will it seem trivial to a third-party consumer?

Let's say this is "good enough," and we want to update our Laravel model to reflect the API we have designed.

Wrangling our Laravel model

There are two things we need to do

  • Update the castings for confirmed and reservation_date
  • Update the with method calls to restrict the returned fields from user references.

Specifying casts in our Laravel model

Models in Laravel extend Illuminate\Database\Eloquent\Model, which implements the Illuminate\Database\Eloquent\Concerns\HasAttributes trait. This trait allows us to identify how fields on our model should be cast when accessed or serialized. Using the $casts property, we can specify the cast types.

The following $casts property should be added to your Reservation model to match the API design we created in Stoplight Studio.

    protected $casts = [
        'confirmed' => 'boolean',
        'reservation_date' => 'date:Y-m-d ',
    ];

With these added, we can refresh the output from Laravel and see that the confirmed and reservation_date now meet our API design expectations! Woo 🥳

Controlling the data returned from relationships

The final step is to update the user model relationships we include. We can do that by restricting the fields returned in our with calls to load relationships. We need to use a colon after the relationship name and specify the fields we want to be returned.

    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        return Reservation::with('approved_by:id,name')
        ->with('reserved_for:id,name')
        ->get();
    }

Adding :id,name ensures only these fields are returned. And, with that change, we now meet our API design specification!

Thanks!

Hopefully you found this interesting! I didn't have time to research existing tooling. The Laravel community has some amazing packages out there, so many there is an OpenAPI and Eloquent ORM model package? If not, I plan on writing one!

If you need assistance with your API driven Laravel or Drupal application, contact Bluehorn Digital at [email protected] and see how my team and myself can assist you!