How To Generate a Swagger/OpenAPI Spec for your Laravel API

You’re investing in your API, and that means finally creating an OpenAPI spec that accurately describes your API. Of course you could write the document by hand, or use a GUI tool to make it easier. Or with just a bit of upfront work, you can generate a complete OpenAPI specification directly from your Laravel application.

Back in the time of Swagger documents, BeyondCode had a well known package for performing spec generation. However, with the advent of OpenAPI, a new package, Scribe (opens in a new tab), has become the go to for generating an OpenAPI Spec from a Laravel API.

An Overview of Scribe

Scribe is a robust documentation solution for PHP APIs. It helps you generate comprehensive human-readable documentation from your Laravel/Lumen/Dingo codebase. That includes HTML documentation, code samples, Postman Collections, and most importantly in our case, OpenAPI specifications.

So let’s start by installing the package using composer, and explore the options available.


composer require --dev knuckleswtf/scribe

Once installed, we want to publish the package configuration so that we can make any changes in how we want this to work.


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

Let’s take a quick look at the specific configuration options that will help us optimize this package to work with our Laravel API:

  • routes - this option allows us to configure how we want to detect the API routes, and prefix we may use and any routes we want to exclude by default.
  • type - we can choose between static (a static HTMI page) and laravel (a Blade view). We will get into more details on the differences later.
  • openapi ****- ****this section allows you to toggle OpenAPI generation on or off. We’ll toggle it on.
  • auth - specify the API’s authentication mechanism. This will be used to describe the security section of the OpenAPI document
  • strategies - this is where we configure how Scribe will interact with our application to get the data needed to create the specification and documentation.

Scribe’s Default OpenAPI Output

As mentioned above, the type config allows us to specify the type of output we get, and also where we want it to be outputted. The static option will generate HTMI documentation pages within our public directory. laravel we will generate this within the storage directory.

Note, anything in the storage directory typically won’t be committed to version control - so you would need to update the .gitignore file if you want to version this. For this article, we will keep the defaults as we want to focus on the OpenAPI Specification not the documentation.

Testing Generation

When we first install and set up this package, we should run a test to make sure that everything is configured correctly and we aren’t going to run into issues further on.


php artisan scribe:generate

You should see a console output similar to the following:


❯ php artisan scribe:generate
ⓘ Processing route: [GET] api/user
✔ Processed route: [GET] api/user
ⓘ Extracting intro and auth Markdown files to: .scribe
✔ Extracted intro and auth Markdown files to: .scribe
ⓘ Writing HTML docs...
✔ Wrote HTML docs and assets to: public/docs/
ⓘ Generating Postman collection
✔ Wrote Postman collection to: public/docs/collection.json
ⓘ Generating OpenAPI specification
✔ Wrote OpenAPI specification to: public/docs/openapi.yaml
Checking for any pending upgrades to your config file...
✔ Visit your docs at http://localhost/docs

So, we can confirm that our package is working correctly with our application, let’s take a look at the OpenAPI Specification that was generated and see what changes are required.


openapi: 3.0.3
info:
title: Laravel
description: ''
version: 1.0.0
servers:
-
url: 'http://localhost'
paths:
/api/user:
get:
summary: ''
operationId: getApiUser
description: ''
parameters: []
responses:
401:
description: ''
content:
application/json:
schema:
type: object
example:
message: Unauthenticated.
properties:
message:
type: string
example: Unauthenticated.
tags:
- Endpoints
security: []
tags:
-
name: Endpoints
description: ''

This is our default set up in Laravel - we are using a project I have yet to add an API to. Let’s add some endpoints so we can see something a little more fleshed out.

Our Example App: The Standup API

We’ll be working on an asynchronous stand-up application, it allows you to do your daily check-ins on one system, enables your manager to have a high level overview of team blockers and mood etc. You can follow along with the GitHub repository here (opens in a new tab).

Non-Optimized Example Output

Now we got that out of the way, let’s regenerate our OpenAPI Specification now that I have added BREAD (Browse, Read, Edit, Add, Delete) endpoints.


openapi: 3.0.3
info:
title: Laravel
description: ''
version: 1.0.0
servers:
-
url: 'http://localhost'
paths:
/api/standups:
get:
summary: ''
operationId: getApiStandups
description: ''
parameters: []
responses:
401:
description: ''
content:
application/json:
schema:
type: object
example:
message: Unauthenticated.
properties:
message:
type: string
example: Unauthenticated.
tags:
- Endpoints
security: []
post:
summary: ''
operationId: postApiStandups
description: ''
parameters: []
responses: { }
tags:
- Endpoints
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mood:
type: string
description: ''
example: excited
enum:
- happy
- sad
- excited
- frustrated
- tired
- neutral
- angry
- anxious
- optimistic
- pensive
- surprised
- sick
- confident
- disappointed
- amused
- relieved
- indifferent
- grateful
- inspired
- confused
tasks:
type: string
description: 'Must be at least 2 characters.'
example: bhwfcupupgcgexmeiuzxvftnsxzwcvllulcenigndwkejgeqjalhsmrsseu
blockers:
type: string
description: 'Must be at least 2 characters.'
example: cwtdgfoqgixwkwhlrwzapudsxtrtoiuldf
questions:
type: string
description: 'Must be at least 2 characters.'
example: nqfytjwyyyxv
comments:
type: string
description: 'Must be at least 2 characters.'
example: ynztxjgszeqzhdqamrfvtnsajozigaivnxbjsrvdujrchjnq
department:
type: string
description: ''
example: quo
required:
- mood
- tasks
- department
security: []
'/api/standups/{uuid}':
get:
summary: ''
operationId: getApiStandupsUuid
description: ''
parameters: []
responses:
401:
description: ''
content:
application/json:
schema:
type: object
example:
message: Unauthenticated.
properties:
message:
type: string
example: Unauthenticated.
tags:
- Endpoints
security: []
parameters:
-
in: path
name: uuid
description: ''
example: eb68e6e5-999a-3a67-a465-afa4b064af3d
required: true
schema:
type: string
'/api/standups/{standUp_id}':
put:
summary: ''
operationId: putApiStandupsStandUp_id
description: ''
parameters: []
responses: { }
tags:
- Endpoints
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mood:
type: string
description: ''
example: pensive
enum:
- happy
- sad
- excited
- frustrated
- tired
- neutral
- angry
- anxious
- optimistic
- pensive
- surprised
- sick
- confident
- disappointed
- amused
- relieved
- indifferent
- grateful
- inspired
- confused
tasks:
type: string
description: 'Must be at least 2 characters.'
example: zeovcuepgdsmjpzdjtycdvcbhkeoxvifmj
blockers:
type: string
description: 'Must be at least 2 characters.'
example: eqursmzxxjivpjqphrlxhritykekqhgsunqbtgvwvypyumuyekvxgzvcviyqa
questions:
type: string
description: 'Must be at least 2 characters.'
example: nwqwclebngkisgxklxnaqrncxpkpzicwplklzpstkrnltjiivjbgmvybbgctihycvwtveebvytrk
comments:
type: string
description: 'Must be at least 2 characters.'
example: vjkatsczlriwefgtiovegcovtzxcngsbiirsyegkfsegwjaandugmbx
department:
type: string
description: ''
example: optio
required:
- mood
- tasks
- department
security: []
delete:
summary: ''
operationId: deleteApiStandupsStandUp_id
description: ''
parameters: []
responses: { }
tags:
- Endpoints
security: []
parameters:
-
in: path
name: standUp_id
description: 'The ID of the standUp.'
example: a
required: true
schema:
type: string
tags:
-
name: Endpoints
description: ''

That was a lot to look through, so let’s do a run through of each path - so we can understand what all this YAML actually means.

Documenting all Response Codes

Let’s focus on the browse endpoint, accessed through /api/standups , which returns a collection of stand-ups that are part of the department you belong to.


/api/standups:
get:
summary: ''
operationId: getApiStandups
description: ''
parameters: []
responses:
401:
description: ''
content:
application/json:
schema:
type: object
example:
message: Unauthenticated.
properties:
message:
type: string
example: Unauthenticated.
tags:
- Endpoints
security: []

You may have noticed that by default Scribe is only documenting the error responses of the API; it's missing how the API would respond successfully.

How Scribe Works

This is a good opportunity to explain about how Scribe works under the hood. Scribe scans your application routes to identify which endpoints should be documented based on your configuration. It then extracts metadata from your routes, such as route names, URI patterns, HTTP methods, and any specific annotations or comments in the controller that might be relevant for documentation.

Scribe then uses the extracted metadata to perform request simulation on your API. It captures the responses that come back, including: status codes, headers, and body content. All this then get packaged into an internal representation of your API, which is how the OpenAPI spec is created.

In the example above, only the 401 is being documented because Scribe hasn’t been configured with the proper authentication information, which makes it unable to access the proper response.

Getting to 200

Let’s modify our Laravel code to get some useful information about our 200 responses.

To achieve this we will use PHP 8.0 Attributes to add additional information to our controllers, this will use the built in Laravel ecosystem to make a request, inspect the information, and write the specification for you. Let’s have a look at our controller:

Adding tags

In OpenAPI, tags are used to group related operations together. Typically, a good way to use tags is to have one tag per "resource" and then associate all the relevant operations that access and modify that resourc together. We'll add a group annotation to the top of the controller.


#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private StandUpRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: true)]
#[Endpoint(title: 'Browse Stand Ups', description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.')]
public function __invoke(Request $request): CollectionResponse
{
$standups = $this->repository->forTeam(
team: $this->auth->user()->current_team_id,
);
return new CollectionResponse(
data: StandUpResource::collection(
resource: $standups->paginate(),
),
);
}
}

Authenicate Scribe

Let’s next focus on the invoke method that is what will be used to generate the path information. We use #[Authenticated] to let Scribe know that this endpoint needs to be authenticated


#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private StandUpRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: true)]
#[Endpoint(title: 'Browse Stand Ups', description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.')]
public function __invoke(Request $request): CollectionResponse
{
$standups = $this->repository->forTeam(
team: $this->auth->user()->current_team_id,
);
return new CollectionResponse(
data: StandUpResource::collection(
resource: $standups->paginate(),
),
);
}
}

Add Descriptions

Use #[Endpoint] to add additional information about this endpoint; describing what it’s function is.


#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private StandUpRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: true)]
#[Endpoint(title: 'Browse Stand Ups', description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.')]
public function __invoke(Request $request): CollectionResponse
{
$standups = $this->repository->forTeam(
team: $this->auth->user()->current_team_id,
);
return new CollectionResponse(
data: StandUpResource::collection(
resource: $standups->paginate(),
),
);
}
}

Adding Responses

Finally, we want to add #[ResponseFromApiResource] to let Scribe know how this API should respond, passing through the resource class and the model itself so Scribe can make a request in the background and inspect the types on the response payload. Also, we pass the boolean flag for whether or not this response should return a collection or not.


#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private StandUpRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: true)]
#[Endpoint(title: 'Browse Stand Ups', description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.')]
public function __invoke(Request $request): CollectionResponse
{
$standups = $this->repository->forTeam(
team: $this->auth->user()->current_team_id,
);
return new CollectionResponse(
data: StandUpResource::collection(
resource: $standups->paginate(),
),
);
}
}

Adding tags

In OpenAPI, tags are used to group related operations together. Typically, a good way to use tags is to have one tag per "resource" and then associate all the relevant operations that access and modify that resourc together. We'll add a group annotation to the top of the controller.

Authenicate Scribe

Let’s next focus on the invoke method that is what will be used to generate the path information. We use #[Authenticated] to let Scribe know that this endpoint needs to be authenticated

Add Descriptions

Use #[Endpoint] to add additional information about this endpoint; describing what it’s function is.

Adding Responses

Finally, we want to add #[ResponseFromApiResource] to let Scribe know how this API should respond, passing through the resource class and the model itself so Scribe can make a request in the background and inspect the types on the response payload. Also, we pass the boolean flag for whether or not this response should return a collection or not.


#[Group(name: 'Stand Ups', description: 'A series of endpoints that allow programatic access to managing stand-ups.', authenticated: true)]
final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private StandUpRepository $repository,
) {
}
#[Authenticated]
#[ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: true)]
#[Endpoint(title: 'Browse Stand Ups', description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.')]
public function __invoke(Request $request): CollectionResponse
{
$standups = $this->repository->forTeam(
team: $this->auth->user()->current_team_id,
);
return new CollectionResponse(
data: StandUpResource::collection(
resource: $standups->paginate(),
),
);
}
}

Now let’s see the OpenAPI spec:


/api/standups:
get:
summary: 'Browse Stand Ups'
operationId: browseStandUps
description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.'
parameters: []
responses:
200:
description: ''
content:
application/json:
schema:
type: object
example:
data:
-
id: ''
type: standUps
attributes:
mood: angry
tasks: "WOULD always get into that lovely garden. First, however, she went slowly after it: 'I never saw one, or heard of \"Uglification,\"' Alice ventured to ask. 'Suppose we change the subject. 'Go on with."
blockers: "I wonder what was the matter on, What would become of you? I gave her answer. 'They're done with a little girl or a worm. The question is, Who in the pool as it was all ridges and furrows; the balls."
questions: 'I to get dry again: they had to stop and untwist it. After a minute or two, they began moving about again, and put it into his cup of tea, and looked at it, and kept doubling itself up very sulkily.'
comments: "Alice, in a large canvas bag, which tied up at this moment the door with his knuckles. It was as much as she ran. 'How surprised he'll be when he sneezes; For he can EVEN finish, if he doesn't."
created:
human: null
timestamp: null
string: null
local: null
-
id: ''
type: standUps
attributes:
mood: pensive
tasks: "She was looking at the Lizard as she picked her way into a graceful zigzag, and was looking down at her for a good many little girls eat eggs quite as much as she couldn't answer either question, it."
blockers: "Alice, 'it's very rude.' The Hatter opened his eyes were nearly out of sight, they were all ornamented with hearts. Next came the guests, mostly Kings and Queens, and among them Alice recognised the."
questions: "After a while she was near enough to look over their slates; 'but it doesn't matter much,' thought Alice, 'shall I NEVER get any older than I am in the last few minutes that she had felt quite."
comments: "An obstacle that came between Him, and ourselves, and it. Don't let him know she liked them best, For this must ever be A secret, kept from all the other was sitting on a little hot tea upon its."
created:
human: null
timestamp: null
string: null
local: null
properties:
data:
type: array
example:
-
id: ''
type: standUps
attributes:
mood: angry
tasks: "WOULD always get into that lovely garden. First, however, she went slowly after it: 'I never saw one, or heard of \"Uglification,\"' Alice ventured to ask. 'Suppose we change the subject. 'Go on with."
blockers: "I wonder what was the matter on, What would become of you? I gave her answer. 'They're done with a little girl or a worm. The question is, Who in the pool as it was all ridges and furrows; the balls."
questions: 'I to get dry again: they had to stop and untwist it. After a minute or two, they began moving about again, and put it into his cup of tea, and looked at it, and kept doubling itself up very sulkily.'
comments: "Alice, in a large canvas bag, which tied up at this moment the door with his knuckles. It was as much as she ran. 'How surprised he'll be when he sneezes; For he can EVEN finish, if he doesn't."
created:
human: null
timestamp: null
string: null
local: null
-
id: ''
type: standUps
attributes:
mood: pensive
tasks: "She was looking at the Lizard as she picked her way into a graceful zigzag, and was looking down at her for a good many little girls eat eggs quite as much as she couldn't answer either question, it."
blockers: "Alice, 'it's very rude.' The Hatter opened his eyes were nearly out of sight, they were all ornamented with hearts. Next came the guests, mostly Kings and Queens, and among them Alice recognised the."
questions: "After a while she was near enough to look over their slates; 'but it doesn't matter much,' thought Alice, 'shall I NEVER get any older than I am in the last few minutes that she had felt quite."
comments: "An obstacle that came between Him, and ourselves, and it. Don't let him know she liked them best, For this must ever be A secret, kept from all the other was sitting on a little hot tea upon its."
created:
human: null
timestamp: null
string: null
local: null
items:
type: object
properties:
id:
type: string
example: ''
type:
type: string
example: standUps
attributes:
type: object
properties:
mood:
type: string
example: angry
tasks:
type: string
example: "WOULD always get into that lovely garden. First, however, she went slowly after it: 'I never saw one, or heard of \"Uglification,\"' Alice ventured to ask. 'Suppose we change the subject. 'Go on with."
blockers:
type: string
example: "I wonder what was the matter on, What would become of you? I gave her answer. 'They're done with a little girl or a worm. The question is, Who in the pool as it was all ridges and furrows; the balls."
questions:
type: string
example: 'I to get dry again: they had to stop and untwist it. After a minute or two, they began moving about again, and put it into his cup of tea, and looked at it, and kept doubling itself up very sulkily.'
comments:
type: string
example: "Alice, in a large canvas bag, which tied up at this moment the door with his knuckles. It was as much as she ran. 'How surprised he'll be when he sneezes; For he can EVEN finish, if he doesn't."
created:
type: object
properties:
human:
type: string
example: null
timestamp:
type: string
example: null
string:
type: string
example: null
local:
type: string
example: null
tags:
- 'Stand Ups'

Documenting Parameters

So far so good! However, this API example is limited. What if we add query parameters like filtering and sorting which we would likely want on a real API.

In terms of Laravel implementation, we recommend use the spatie/laravel-query-builder package to enable JSON:API style filtering on my API, as it ties directly into Eloquent ORM from the request parameters. Let’s start adding some filters.

Our controller code used our StandUpRepository which just leverages Eloquent to query our database through a shared abstraction. However, we want to lean on the package by Spatie, which has a slightly different approach. Let’s rewrite this code to make it more flexible.


#[Authenticated]
#[Endpoint(title: 'Browse Stand Ups', description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.')]
#[ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: true)]
public function __invoke(Request $request): CollectionResponse
{
$standups = QueryBuilder::for(
subject: $this->repository->forTeam(
team: $this->auth->user()->current_team_id,
),
)->allowedFilters(
filters: $this->repository->filters(),
)->allowedIncludes(
includes: $this->repository->includes(),
)->allowedSorts(
sorts: $this->repository->sort(),
)->getEloquentBuilder();
return new CollectionResponse(
data: StandUpResource::collection(
resource: $standups->paginate(),
),
);
}

We use the QueryBuilder class from the package, to pass in the result of our repository call. The repository is just passing a pre-built query back, which we can use to paginate or extend as required. I prefer this approach as the sometimes you want to tie multiple methods together. You will see that I have 4 new methods that weren’t there before:

  • allowedFilters
  • allowedIncludes
  • allowedSorts
  • getEloquentBuilder

The first three allow you to programmatically control what parts of the query parameters to use and which to ignore. The final one is to get back the eloquent query builder, that we want to use as we know the API for it. The package returns a custom query builder, which does not have all of the methods we may want. Let’s flesh out the filter, include, and sort method calls next.

Going back we add attributes that will be parsed - so that our OpenAPI spec is generated with all available options:


final readonly class IndexController
{
public function __construct(
private AuthManager $auth,
private StandUpRepository $repository,
) {
}
#[
Authenticated,
QueryParam(name: 'filter[mood]', type: 'string', description: 'Filter the results by mood', required: false, example: 'filter[mood]=neutral', enum: Mood::class),
QueryParam(name: 'filter[name]', type: 'string', description: 'Filter the results by the users name', required: false, example: 'filter[mood]=Rumpelstiltskin'),
QueryParam(name: 'filter[department]', type: 'string', description: 'Filter the results by the department name', required: false, example: 'Engineering'),
QueryParam(name: 'include', type: 'string', description: 'A comma separated list of relationships to side-load', required: false, example: 'include=user,department.team'),
QueryParam(name: 'sort', type: 'string', description: 'Sort the results based on either the mood, or the created_at', required: false, example: 'sort=-mood'),
ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: true),
Endpoint(title: 'Browse Stand Ups', description: 'Browse through the stand-ups that belong to your team, no matter what department you are in.')
]
public function __invoke(Request $request): CollectionResponse
{
$standups = $this->repository->forTeam(
team: $this->auth->user()->current_team_id,
);
return new CollectionResponse(
data: StandUpResource::collection(
resource: $standups->paginate(),
),
);
}
}

Info Icon

NOTE

You may have noticed that the syntax has collapsed all the metadata into one attribute. It’s just a code style choice, there’s no change in functionality.

The result of the above will be the following inside your OpenAPI specification:


parameters:
-
in: query
name: 'filter[mood]'
description: 'Filter the results by mood'
example: 'filter[mood]=neutral'
required: false
schema:
type: string
description: 'Filter the results by mood'
example: 'filter[mood]=neutral'
enum:
- happy
- sad
- excited
- frustrated
- tired
- neutral
- angry
- anxious
- optimistic
- pensive
- surprised
- sick
- confident
- disappointed
- amused
- relieved
- indifferent
- grateful
- inspired
- confused
-
in: query
name: 'filter[name]'
description: 'Filter the results by the users name'
example: 'filter[mood]=Rumpelstiltskin'
required: false
schema:
type: string
description: 'Filter the results by the users name'
example: 'filter[mood]=Rumpelstiltskin'
-
in: query
name: 'filter[department]'
description: 'Filter the results by the department name'
example: Engineering
required: false
schema:
type: string
description: 'Filter the results by the department name'
example: Engineering
-
in: query
name: include
description: 'A comma separated list of relationships to side-load'
example: 'include=user,department.team'
required: false
schema:
type: string
description: 'A comma separated list of relationships to side-load'
example: 'include=user,department.team'
-
in: query
name: sort
description: 'Sort the results based on either the mood, or the created_at'
example: sort=-mood
required: false
schema:
type: string
description: 'Sort the results based on either the mood, or the created_at'
example: sort=-mood

Quite convenient I am sure you can agree!

A More Complex Endpoint

Let’s now move onto documenting our store endpoint which is what is used to create a new stand-up.


/api/standups:
post:
summary: ''
operationId: postApiStandups
description: ''
parameters: []
responses: { }
tags:
- Endpoints
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mood:
type: string
description: ''
example: excited
enum:
- happy
- sad
- excited
- frustrated
- tired
- neutral
- angry
- anxious
- optimistic
- pensive
- surprised
- sick
- confident
- disappointed
- amused
- relieved
- indifferent
- grateful
- inspired
- confused
tasks:
type: string
description: 'Must be at least 2 characters.'
example: fsukiymcjmglqdyuuecbuhdlplot
blockers:
type: string
description: 'Must be at least 2 characters.'
example: xxqzeornblypfisimgvgucodtqracytnncacoqxqaeuzytrvmezydvztnqtmrmbgdebrfdmgkmjczytt
questions:
type: string
description: 'Must be at least 2 characters.'
example: ckmhwsbrdoryyfdxhidyrbugkaftcyiozxzsdtahbnsdivqferixcflplmadjarlyosbn
comments:
type: string
description: 'Must be at least 2 characters.'
example: kbczrybawedlzxhpzyhcorgzjmsgcdvdbgryjaqhwsbccxwyfkprfhnpogyqjuyyramuqrkzzsypaajoegiu
department:
type: string
description: ''
example: pariatur
required:
- mood
- tasks
- department
security: []

For the most part, this has been documented quite well by leaning on the Laravel framework and understanding what the validation rules on the request means. Let’s enhance this by adding some information.


#[
Group(name: 'Stand Ups', description: 'A series of endpoints that allow programmatic access to managing stand-ups.', authenticated: true),
Authenticated,
Endpoint(title: 'Create a new Stand Up', description: 'Create a new Stand Up for a specified department, will be assigned to whichever user is authenticated at the time.'),
]

This is similar to what we did on the IndexController but this time we are jumping straight into grouping the attributes all together at the top of the class. We do not need to add these above the invoke method, as this class only performs the one action anyway. I would consider moving these if I were to leverage additional Attributes for different purposes on the method, however for now I am not. Let’s now regenerate the OpenAPI Specification to see what the difference is, but this time I am going to omit the request validation information.


post:
summary: 'Create a new Stand Up'
operationId: createANewStandUp
description: 'Create a new Stand Up for a specified department, will be assigned to whichever user is authenticated at the time.'
parameters: []
responses: { }
tags:
- 'Stand Ups'
requestBody:
required: true
content:

As you can see, the information is starting to build up based on the information we pass through to the PHP Attributes. Let’s start expanding on the request body and response information and build a better specification.


#[
Authenticated,
Group(name: 'Stand Ups', description: 'A series of endpoints that allow programmatic access to managing stand-ups.', authenticated: true),
Endpoint(title: 'Create a new Stand Up', description: 'Create a new Stand Up for a specified department, will be assigned to whichever user is authenticated at the time.'),
BodyParam(name: 'mood', type: 'string', description: 'The mood of the user to be submitted to the stand-up.', required: true, example: 'neutral', enum: Mood::class),
BodyParam(name: 'tasks', type: 'string', description: 'The list of tasks the user is planning on working on today. Markdown is supported.', required: false, example: 'Today I will be working on the OpenAPI Specification.'),
BodyParam(name: 'blockers', type: 'string', description: 'A list of things that are blocking the user from progressing. Markdown is supported.', required: false, example: 'I am currently being blocked by front-end playing with crayons.'),
BodyParam(name: 'questions', type: 'string', description: 'A list of questions that the user wants information on, these could be anything. Markdown is supported.', required: false, example: 'How much wood, could a woodchuck chuck, if a woodchuck, could chuck wood.'),
BodyParam(name: 'comments', type: 'string', description: 'Any comments that the user wants to add to their stand-up that may be useful.', required: false, example: 'Going to the Dentist at 2pm, will make up hours later.'),
BodyParam(name: 'department', type: 'string', description: 'The Unique Identifier for the department that the user is adding their stand up to.', required: true, example: '1234-1234-1234-1234'),
ResponseFromApiResource(StandUpResource::class, StandUp::class, collection: false)
]

Now we have the body parameters for this request, as well as how the API is expected to respond. We are currently only documenting the happy path - as we have yet to decide how we want to handle errors. This will create the following in your OpenAPI Specification:


post:
summary: 'Create a new Stand Up'
operationId: createANewStandUp
description: 'Create a new Stand Up for a specified department, will be assigned to whichever user is authenticated at the time.'
parameters: []
responses:
200:
description: ''
content:
application/json:
schema:
type: object
example:
data:
id: 9bce14db-cdd1-4a8a-86cb-e05f9f918d20
type: standUps
attributes:
mood: sick
tasks: "Tortoise, if he were trying which word sounded best. Some of the deepest contempt. 'I've seen hatters before,' she said to Alice; and Alice looked all round the court and got behind him, and said."
blockers: "Hatter with a soldier on each side, and opened their eyes and mouths so VERY remarkable in that; nor did Alice think it so quickly that the cause of this elegant thimble'; and, when it had made. 'He."
questions: "Alice. 'Come on, then!' roared the Queen, 'and he shall tell you my adventures--beginning from this morning,' said Alice desperately: 'he's perfectly idiotic!' And she began again: 'Ou est ma."
comments: 'Alice felt a little ledge of rock, and, as there was nothing else to say "HOW DOTH THE LITTLE BUSY BEE," but it was written to nobody, which isn''t usual, you know.'' Alice had never been so much.'
created:
human: '0 seconds ago'
timestamp: 1713094155
string: '2024-04-14 11:29:15'
local: '2024-04-14T11:29:15'
properties:
data:
type: object
properties:
id:
type: string
example: 9bce14db-cdd1-4a8a-86cb-e05f9f918d20
type:
type: string
example: standUps
attributes:
type: object
properties:
mood:
type: string
example: sick
tasks:
type: string
example: "Tortoise, if he were trying which word sounded best. Some of the deepest contempt. 'I've seen hatters before,' she said to Alice; and Alice looked all round the court and got behind him, and said."
blockers:
type: string
example: "Hatter with a soldier on each side, and opened their eyes and mouths so VERY remarkable in that; nor did Alice think it so quickly that the cause of this elegant thimble'; and, when it had made. 'He."
questions:
type: string
example: "Alice. 'Come on, then!' roared the Queen, 'and he shall tell you my adventures--beginning from this morning,' said Alice desperately: 'he's perfectly idiotic!' And she began again: 'Ou est ma."
comments:
type: string
example: 'Alice felt a little ledge of rock, and, as there was nothing else to say "HOW DOTH THE LITTLE BUSY BEE," but it was written to nobody, which isn''t usual, you know.'' Alice had never been so much.'
created:
type: object
properties:
human:
type: string
example: '0 seconds ago'
timestamp:
type: integer
example: 1713094155
string:
type: string
example: '2024-04-14 11:29:15'
local:
type: string
example: '2024-04-14T11:29:15'
tags:
- 'Stand Ups'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mood:
type: string
description: 'The mood of the user to be submitted to the stand-up.'
example: neutral
enum:
- happy
- sad
- excited
- frustrated
- tired
- neutral
- angry
- anxious
- optimistic
- pensive
- surprised
- sick
- confident
- disappointed
- amused
- relieved
- indifferent
- grateful
- inspired
- confused
tasks:
type: string
description: 'The list of tasks the user is planning on working on today. Markdown is supported.'
example: 'Today I will be working on the OpenAPI Specification.'
blockers:
type: string
description: 'A list of things that are blocking the user from progressing. Markdown is supported.'
example: 'I am currently being blocked by front-end playing with crayons.'
questions:
type: string
description: 'A list of questions that the user wants information on, these could be anything. Markdown is supported.'
example: 'How much wood, could a woodchuck chuck, if a woodchuck, could chuck wood.'
comments:
type: string
description: 'Any comments that the user wants to add to their stand-up that may be useful.'
example: 'Going to the Dentist at 2pm, will make up hours later.'
department:
type: string
description: 'The Unique Identifier for the department that the user is adding their stand up to.'
example: 1234-1234-1234-1234
required:
- mood
- department

As you can see, a lot more information is provided which will help anyone who wants to interact with this API.

Summary

If we follow this approach throughout our API, we can generate a well documented OpenAPI Specification for our Laravel based API - utilizing modern PHP to add information to our code base. This not only aids in the OpenAPI generation, but it also adds a level of in-code documentation that will help onboard any new developer who needs to know what the purpose of an endpoint may be.