Back to Course |
Re-creating Booking.com API with Laravel and PHPUnit

Generate API Documentation with Scribe

Documenting your API could become tedious, but there are quite a few great tools (like Scribe) that make this a breeze and produce results like this:

And yes, this is a real documentation website that we published, you can visit the documentation here!


Installation

To install Scribe you will need to require it using composer:

composer require --dev knuckleswtf/scribe

Next, you will need to publish the config file:

php artisan vendor:publish --tag=scribe-config

This will publish a scribe.php file in your config directory where we will later add our configuration to make sure Scribe works as we want it to.

To generate the documentation run:

php artisan scribe:generate

And it will generate the documentation in the public/docs directory. You can open it in your browser by visiting APP_URL/docs/index.html:

On our first run, it found all of our Routes immediately and generated the documentation for them!


Configuring Scribe

Now that we have Scribe, we should customize it to fit our needs. Open up the config/scribe.php file and let's go through each of the configuration options that are worth noting:

title - This is the title of your API documentation. This will be displayed at the top of the page.

description - This is a description of your API documentation displayed in the Introduction, Postman Collection, and OpenAPI sections.

base_url - This is the base URL of your API. By default, it is null and will use the URL of your application but if you want to generate the API docs locally - you should fill it with your production URL.

routes - In here we have a lot of different settings for Route patterns, exceptions, and so on. The documentation comments are quite extensive, so I won't go into many details. For our project defaults are okay as we have a /api prefix for our API Routes.

type - This changes the type of documentation you will have. Static is just a basic HTML page and Laravel will create a Route for it with blade files.

static - This is the configuration for the static documentation. You can change the output directory.

laravel - This defines url, middleware, and group for the Laravel-generated documentation. You should use this and secure it with middleware!

try_it_out - It adds a "Try it out" button to the documentation. It is a great feature, but it requires a lot of configuration and a static key in your .env file. I won't cover it in this tutorial, but you can read more about it in the official documentation.

auth - This configures the authentication for your API. It can use a set key to auto-generate responses from your endpoints. We will use this feature in this tutorial.

intro_text - Custom text that will be displayed in the Introduction section.

example_languages - Great way to show examples in different languages. You can fine-tune which languages you want to show and which you don't.

postman - Configures if we generate a Postman collection or not. It's great because you can just download a collection and import it into Postman to start testing your API.

Button in the sidebar

openapi - Configures if we generate OpenAPI documentation or not. It's a great way to generate documentation for your API and use it in other tools that support OpenAPI specifications.

groups - Allows you to group your Routes into different sections on the sidebar. You can also customize the order of the groups.

logo - Allows you to add a logo to your documentation. It will be displayed in the top left corner. Brand awareness is good to keep in mind!

That's it, these are the most important configuration options. You can read more about them in the official documentation.

Our Changes

Aside from the obvious changes like title and description we will make the following changes:

  • try_it_out.enabled - Set to false as we won't be supporting this feature, and it would be a broken link. (We can cover this in another tutorial if needed!)
  • auth.enabled - Set to true as we will be using this feature.
  • auth.in - Set to header as we are using Sanctum, and it requires a header.
  • auth.name - Set to X-XSRF-TOKEN as required by Sanctum.
  • auth.extra_info - Set to You can retrieve your token by making a request to /sanctum/csrf-cookie as it is displayed in the documentation. It could be more extensive to prevent confusion!
  • example_languages - Will be set to ['bash', 'javascript', 'php'] as we will be using these languages in our examples.
  • Routes.apply.response_calls - We will set this to [] as we don't want to make automated calls. This way, we will not have 401 responses everywhere.

That's it for the configuration. Now we can move on to the next step and write our first API documentation!


Documenting the API

While documenting the API I'll explain the parameters used if they are new and not seen before. This way you can see how they work and what they do.

To document the function itself, you need to follow the following format:

/**
* NAME_IN_SIDEBAR
*
* [DESCRIPTION_OF_ROUTE]
*
* BODY_OR_QUERY_PARAMS
*
* RESPONSES
*
*/

Let's start with our Registration:

app/Http/Controllers/Auth/RegisterController.php

/**
* @group Authentication
*/
class RegisterController extends Controller
{
/**
* Register a new user.
*
* [Creates a new user and returns a token for authentication.]
*
* @response {"access_token":"1|a9ZcYzIrLURVGx6Xe41HKj1CrNsxRxe4pLA2oISo"}
* @response 422 {"message":"The selected role id is invalid.","errors":{"role_id":["The selected role id is invalid."]}}
*/
public function __invoke(Request $request)
{
// ...
}
}

Generating API docs will give us this:

Let's go through the parameters we've used:

  • @group - This is the group that the Route will be in. It will be displayed in the sidebar. You can also use @group on the class level to group all the Routes in the class.
  • @authenticated - This indicates that the Route requires authentication. It will be displayed in the documentation.
  • @response - This is the response that the Route will return. You can have multiple responses, and they will be displayed in the documentation. You can even add a status code to the response, and it will be displayed in the documentation with the correct response code.
  • Missing body parameters, yet they are in the documentation. This is because Scribe is smart to retrieve them from Laravel Validation rules. Sometimes it is still worth defining them but not always.

While there is no real limit on responses - make sure you have the most relevant ones! Don't push all 422 for example, as this will just clutter the documentation.

Next up are Owner Routes:

app/Http/Controllers/Owner/PropertyController.php

/**
* @group Owner
* @subgroup Property management
*/
class PropertyController extends Controller
{
/**
* Properties list - **INCOMPLETE**
*
* [List of owners properties]
*
* @authenticated
*
* @response {"success": true}
*
*/
public function index()
{
// ...
}
 
/**
* Store property
*
* [Stores new property of the owner]
*
* @authenticated
*
* @response {"name":"My property","city_id":1,"address_street":"Street Address 1","address_postcode":"12345","owner_id":2,"updated_at":"2023-05-10T07:07:45.000000Z","created_at":"2023-05-10T07:07:45.000000Z","id":1,"city":{"id":1,"country_id":1,"name":"New York","lat":"40.7127760","long":"-74.0059740","created_at":"2023-05-10T07:07:45.000000Z","updated_at":"2023-05-10T07:07:45.000000Z","country":{"id":1,"name":"United States","lat":"37.0902400","long":"-95.7128910","created_at":"2023-05-10T07:07:45.000000Z","updated_at":"2023-05-10T07:07:45.000000Z"}}}
*/
public function store(StorePropertyRequest $request)
{
// ...
}
}

One new thing you might have noticed is the @subgroup tag. This allows you to create subgroups in the sidebar. It's a great way to organize your Routes even more!

app/Http/Controllers/Owner/PropertyPhotoController.php

/**
* @group Owner
* @subgroup Property photo management
*/
class PropertyPhotoController extends Controller
{
/**
* Add a photo to a property
*
* [Adds a photo to a property and returns the filename, thumbnail and position of the photo]
*
* @authenticated
*
* @response {"filename": "http://localhost:8000/storage/properties/1/photos/1/IMG_20190601_123456.jpg", "thumbnail": "http://localhost:8000/storage/properties/1/photos/1/conversions/thumbnail.jpg", "position": 1}
* @response 422 {"message":"The photo must be an image.","errors":{"photo":["The photo must be an image."]}}
*/
public function store(Property $property, Request $request)
{
// ...
}
 
/**
* Reorder photos of a property
*
* [Reorders photos of a property and returns the new position of the photo]
*
* @authenticated
*
* @urlParam newPosition integer The new position of the photo. Example: 2
*
* @response {"newPosition": 2}
*/
public function reorder(Property $property, Media $photo, int $newPosition)
{
// ...
}
}

Here we have a new parameter @urlParam. This is used to define URL parameters, in our case to tell that it's an int type.

Next, we will go into the Public facing Routes:

app/Http/Controllers/Public/ApartmentController.php

/**
* @group Public
* @subgroup Apartments
*/
class ApartmentController extends Controller
{
/**
* Get apartment details
*
* [Returns details about a specific apartment]
*
* @response {"name":"Large apartment","type":null,"size":null,"beds_list":"","bathrooms":0,"facility_categories":{"First category":["First facility","Second facility"],"Second category":["Third facility"]}}
*/
public function __invoke(Apartment $apartment)
{
// ...
}
}

app/Http/Controllers/Public/PropertyController.php

/**
* @group Public
* @subgroup Property
*/
class PropertyController extends Controller
{
/**
* Property details
*
* [Returns details of a property]
*
* @response {"properties":{"data":[{"id":1,"name":"Aspernatur nostrum.","address":"5716 Leann Point, 24974-6081, New York","lat":"8.8008940","long":"-82.9095500","apartments":[{"name":"Mid size apartment","type":null,"size":null,"beds_list":"","bathrooms":0,"price":0}],"photos":[],"avg_rating":null}],"links":{"first":"http:\/\/booking-com-simulation-laravel.test\/api\/search?city=1&adults=2&children=1&page=1","last":"http:\/\/booking-com-simulation-laravel.test\/api\/search?city=1&adults=2&children=1&page=1","prev":null,"next":null},"meta":{"current_page":1,"from":1,"last_page":1,"links":[{"url":null,"label":"« Previous","active":false},{"url":"http:\/\/booking-com-simulation-laravel.test\/api\/search?city=1&adults=2&children=1&page=1","label":"1","active":true},{"url":null,"label":"Next »","active":false}],"path":"http:\/\/booking-com-simulation-laravel.test\/api\/search","per_page":10,"to":1,"total":1}},"facilities":[]}
*/
public function __invoke(Property $property, Request $request)
{
// ...
}

app/Http/Controllers/Public/PropertySearchController.php

/**
* @group Public
* @subgroup Property search
*/
class PropertySearchController extends Controller
{
/**
* Search properties
*
* [Returns a list of filtered properties]
*
* @queryParam city int City ID. Example: 1
* @queryParam country int Country ID. Example: 4
* @queryParam geoobject int Geoobject ID. Example: 1
* @queryParam adults int Number of adults. Example: 2
* @queryParam children int Number of children. Example: 1
* @queryParam facilities array List of facility IDs. Example: [1, 2, 3]
* @queryParam price_from int Minimum price. Example: 100
* @queryParam price_to int Maximum price. Example: 200
* @queryParam start_date date Start date. Example: 2024-01-01
* @queryParam end_date date End date. Example: 2024-01-03
*
* @response {"properties":{"data":[{"id":2,"name":"Qui velit ea.","address":"2392 Zemlak Port Suite 655, 16225-4383, New York","lat":"-54.8191470","long":"-70.2183380","apartments":[{"name":"Mid size apartment","type":null,"size":null,"beds_list":"","bathrooms":0,"price":0}],"photos":[],"avg_rating":8},{"id":1,"name":"Provident enim est.","address":"1487 Ignacio Alley Suite 794, 74215, New York","lat":"13.2359740","long":"-74.2809120","apartments":[{"name":"Cheap apartment","type":null,"size":null,"beds_list":"","bathrooms":0,"price":0}],"photos":[],"avg_rating":7}],"links":{"first":"http:\/\/booking-com-simulation-laravel.test\/api\/search?city=1&adults=2&children=1&page=1","last":"http:\/\/booking-com-simulation-laravel.test\/api\/search?city=1&adults=2&children=1&page=1","prev":null,"next":null},"meta":{"current_page":1,"from":1,"last_page":1,"links":[{"url":null,"label":"« Previous","active":false},{"url":"http:\/\/booking-com-simulation-laravel.test\/api\/search?city=1&adults=2&children=1&page=1","label":"1","active":true},{"url":null,"label":"Next »","active":false}],"path":"http:\/\/booking-com-simulation-laravel.test\/api\/search","per_page":10,"to":2,"total":2}},"facilities":[]}
*/
public function __invoke(Request $request)
{
// ...
}
}

We have introduced a new tag here - @queryParam. This is used to define query parameters that we might have in our request. In our case we have a lot of them, so we have to define them all.

And lastly, we have to define all of our Users' Routes for their Bookings:

app/Http/Controllers/User/BookingController.php

 
/**
* @group User
* @subgroup Bookings
*/
class BookingController extends Controller
{
/**
* List of user bookings
*
* [Returns preview list of all user bookings]
*
* @authenticated
*
* @response {"id":1,"apartment_name":"Fugiat saepe sed.: Apartment","start_date":"2023-05-11","end_date":"2023-05-12","guests_adults":1,"guests_children":0,"total_price":0,"cancelled_at":null,"rating":null,"review_comment":null}
*/
public function index()
{
// ...
}
 
/**
* Create new booking
*
* [Creates new booking for authenticated user]
*
* @authenticated
*
* @response 201 {"id":1,"apartment_name":"Hic consequatur qui.: Apartment","start_date":"2023-05-11 08:00:51","end_date":"2023-05-12 08:00:51","guests_adults":2,"guests_children":1,"total_price":0,"cancelled_at":null,"rating":null,"review_comment":null}
*/
public function store(StoreBookingRequest $request)
{
// ...
}
 
/**
* View booking
*
* [Returns details about a booking]
*
* @authenticated
*
* @response {"id":1,"apartment_name":"Hic consequatur qui.: Apartment","start_date":"2023-05-11 08:00:51","end_date":"2023-05-12 08:00:51","guests_adults":2,"guests_children":1,"total_price":0,"cancelled_at":null,"rating":null,"review_comment":null}
*/
public function show(Booking $booking)
{
// ...
}
 
/**
* Update existing booking rating
*
* [Updates booking with new details]
*
* @authenticated
*
* @response {"id":1,"apartment_name":"Hic consequatur qui.: Apartment","start_date":"2023-05-11 08:00:51","end_date":"2023-05-12 08:00:51","guests_adults":2,"guests_children":1,"total_price":0,"cancelled_at":null,"rating":null,"review_comment":null}
*/
public function update(Booking $booking, UpdateBookingRequest $request)
{
// ...
}
 
/**
* Delete booking
*
* [Deletes a booking]
*
* @authenticated
*
* @response {}
*/
public function destroy(Booking $booking)
{
// ...
}
}

And that's it! We have documented our API, and we are already generating our API documentation!

You can preview the documentation by going to this link.


Consuming API with Swagger

Scribe generates an OpenAPI 3.0 specification file, which can be used to generate a Swagger UI. To do this, you have to download the file from the sidebar:

Once that is done, you can go into Swagger UI and import the file. For example, we've imported it as a single HTML file:

public/swagger.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="SwaggerUI"
/>
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: 'https://booking-com-simulation-laravel.test/docs/openapi.yaml',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout",
});
};
</script>
</body>
</html>

Where we defined the url to be the file we downloaded from Scribe. This is what it looks like when we visit https://booking-com-simulation-laravel.test/swagger.html:

And all of our endpoints are shown here:

That's it! You can now use the Swagger UI to view the documentation

You can preview the documentation by going to this link.


Few Notes

Scribe can become a little bit slow if you have hundreds of API endpoints as it is a single page with everything rendered at once.

Scribe also supports PHP 8.1 Attributes syntax:

app/Http/Controllers/Auth/RegisterController.php

#[Group('Auth')]
class RegisterController extends Controller
{
#[Endpoint('Register a new user', <<<DESC
[Creates a new user and returns a token for authentication.]
DESC
)]
#[Response([
"access_token" => "1|a9ZcYzIrLURVGx6Xe41HKj1CrNsxRxe4pLA2oISo",
])]
#[Response([
"message" => "The selected role id is invalid.",
"errors" => [
"role_id" => [
"The selected role id is invalid.",
],
],
], 422)]
public function __invoke(Request $request)
{
// ...
}
}

And it will still produce the same output, just with a different syntax.