This is Part III of a multi-part series. Below are the links to other parts of this tutorial!
- OpenAPI Tutorial Part I: Introduction to OpenAPI
- OpenAPI Tutorial Part II: Common API Example
- OpenAPI Tutorial Part III: Paths and Basic Request Data
The end result of this article can be found at jtreminio/openapi-tutorial branch “part-iii”. You can clone it by doing
$ git clone git@github.com:jtreminio/openapi-tutorial.gitMake sure to checkout branch
part-iii.
OpenAPI Tutorial Part II: Common API Example introduced a basic API codebase that we will begin adding OpenAPI support to.
Today we will go over adding paths, and the basic types of request data.
Is Redoc Documentation Server Running?
Before you go any further, make sure your Redoc server is running:
1
2
3
4
$ ./bin/docs.sh
Server started: http://127.0.0.1:8080
👀 Watching ./ for changes...
Adding Paths
You will notice that the jtreminio/openapi-tutorial already contains some helpful comments to help developers know how each endpoint is supposed to work.
For example:
1
2
3
4
5
6
7
8
9
10
11
class PetController
{
/**
* POST /pet
*/
public function petCreate(RequestInterface $request): string
{
// ...
}
}
There are also endpoints using path parameters with these comments:
1
2
3
4
5
6
7
8
/**
* PUT /pet/{id}/photo
*/
public function petUpdatePhoto(RequestInterface $request): string
{
// ...
}
These comments are strictly for human readers, they do not affect your OpenAPI definitions. I included them because I expect many of your existing APIs might contain similar data. This might be the case for other parts of the codebase - you and your team have probably left behind comments to help each other better understand what the intended purpose of an endpoint is, how it is supposed to be called, what parameters it expects.
The benefit of using OpenAPI is you begin codifying these random and haphazard comments into a strict definition.
Definition of a Path
Paths are the endpoints your API makes available for users. They will contain the verb (GET, POST, PUT, DELETE, etc) and, if applicable, path parameters.
Paths combine the verb and the endpoint to create a unique definition. All this means is that POST /order is a different path than UPDATE /order.
Your First Path Definition
Let’s begin with PetController::petCreate(), which does not have any path parameters:
1
2
3
4
5
6
7
8
/**
* POST /pet
*/
public function petCreate(RequestInterface $request): string
{
// ...
}
Import the OpenApi\Annotations annotations namespace at the top of the class:
1
2
3
4
5
6
7
8
9
10
<?php
declare(strict_types=1);
namespace PetStoreApi\Controller;
use OpenApi\Annotations as OA;
use PetStoreApi\Model;
use PetStoreApi\RequestInterface;
Now, add our first path definition:
1
2
3
4
5
6
7
8
9
10
11
12
class PetController
{
/**
* @OA\Post(path="/pet",
* summary="Add a new pet",
* operationId="petCreate",
* tags={"Pet"},
* )
*/
public function petCreate(RequestInterface $request): string
{
Finally, generate the new definitions file:
1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./bin/generate.php
Warning: @OA\Post() requires at least one @OA\Response()
in \PetStoreApi\Controller\PetController->petCreate()
in ./src/Controller/PetController.php on line 20
in ./vendor/zircote/swagger-php/src/Loggers/DefaultLogger.php on line 27
Wrote OAS file to ./bin/../openapi.yaml
Running php-cs-fixer
Loaded config default from "./.php-cs-fixer.php".
Using cache file ".php-cs-fixer.cache".
Ignore the warning for now, reload your Redoc browser window, and you should see the beginning of some documentation.
![]() |
|---|
| Your first endpoint definition |
If you take a look at your openapi.yaml file you should see the change in the definition:
1
2
3
4
5
6
7
8
9
# ...
paths:
/pet:
post:
tags:
- Pet
summary: 'Add a new pet'
operationId: petCreate
Break It Down - Path Annotations
These four new lines of data are the core pieces of a path definition. Let’s go over each part before moving on.
Verb + Path
In our PetController::petCreate() we have added the following:
1
2
3
4
5
6
7
8
9
10
11
12
class PetController
{
/**
* @OA\Post(path="/pet",
* summary="Add a new pet",
* operationId="petCreate",
* tags={"Pet"},
* )
*/
public function petCreate(RequestInterface $request): string
{
This is a path definition. zircote/swagger-php has an annotation for each major verb:
@OA\Delete()@OA\Get()@OA\Head()@OA\Options()@OA\Patch()@OA\Post()@OA\Put()@OA\Trace()
You really only need @OA\Delete(), @OA\Get(), @OA\Post(), and @OA\Put().
The type of annotation you select and the path value will be combined in the generated definition.
@OA\Post(path="/pet") generates:
1
2
3
4
5
paths:
/pet:
post:
# ...
While @OA\Get(path="/pet") generates:
1
2
3
4
5
paths:
/pet:
get:
# ...
If you have two paths, one with @OA\Post(path="/pet") and the other with @OA\Get(path="/pet") you can clearly see how OpenAPI considers these separate definitions:
1
2
3
4
5
6
7
paths:
/pet:
get:
# ...
post:
# ...
Summary & Description
We also define a summary value, summary="Add a new pet". In Redoc summary is used for the left sidebar listing all endpoints available, along with the verb (Post). Each documentation generator will work differently and if you are using something else you will need to experiment yourself!
There is also description which we have not used yet. It is similar to summary except longer.
Operation ID
operationId is a unique identifier for this path, operationId="petCreate". It must be unique across your entire definitions file. The convention is up to you, some teams like to use {verb}{Path}{Action} like postPetCreate. Others use {action}{PathRoot} like createPet.
I use {pathRoot}{Action} like petCreate. It helps group common paths together:
petCreatepetGetpetUpdatepetDeletepetListpetFindBypetUpdatePhoto
Tags
Finally, we use tags to group common functionality together, tags={"Pet"}.
In Redoc this groups paths under a dropdown. You can think of them as categories. We will eventually have “Pet”, “Customer”, and “Store” tags. One endpoint can have one or more tags.
A note about curly braces
{ }.zircote/swagger-phpuses thedoctrine/annotationslibrary to handle reading annotations. Unfortunately this library does not (currently) support PHP-style array braces[ ], which means that even for true arrays we must use curly braces{ }.You would normally expect to see
tags=["Pet"]but instead we must usetags={"Pet"}.
Now it is time to let OpenAPI know what parameters this endpoint expects!
Adding a Body Definition
The POST /pet is expecting data as part of the body request, and a possible file upload.
| parameter | type |
|---|---|
name | string |
age | integer |
type | string dog, cat, or fish |
status | string available, pending, or sold |
info | object DogInfo, CatInfo, or FishInfo |
photo | binary (file upload) |
This endpoint does not use the id property in the Model\Pet class. We will get to that later.
Body Annotations
You tell the annotations library this is a body schema by using @OA\Schema() and defining the schema property:
1
2
3
4
5
6
/**
* @OA\Schema(schema="Pet")
*/
class Pet
{
Add an @OA\Property() definition to the name property:
1
2
3
4
5
6
/**
* @var string
* @OA\Property()
*/
public $name;
The zircote/swagger-php annotation library supports auto-detecting the property type by reading the @var attached to each property, or detecting any default value assigned to the property. However, I find it is best to explicitly define the type:
1
2
3
4
5
6
/**
* @var string
* @OA\Property(type="string")
*/
public $name;
Now, if you generate the definitions file again ($ ./bin/generate.php) you will see the new schema:
1
2
3
4
5
6
7
8
components:
schemas:
Pet:
properties:
name:
type: string
type: object
Do the same for age, type, and status:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @var int
* @OA\Property(type="integer")
*/
public $age;
// ...
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
// ...
/**
* @var string "available"|"pending"|"sold"
* @OA\Property(type="string")
*/
public $status = 'available';
So far the types are simple, type="integer" and type="string". What would you expect a file upload to be? If you guessed type="file" or type="binary" you would be forgiven for being wrong.
1
2
3
4
5
6
7
8
/**
* @var \SplFileInfo|null
* @OA\Property(type="string",
* format="binary",
* )
*/
public $photo = null;
For now, let’s skip over adding a definition for
info. I promise we will come back to it in later parts of this series, but discriminators are a bit more advanced than what you are learning right now.
Regenerate your definitions file ($ ./bin/generate.php) and behold:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
components:
schemas:
Pet:
properties:
name:
type: string
age:
type: integer
photo:
type: string
format: binary
type:
description: '"dog"|"cat"|"fish"'
type: string
status:
description: '"available"|"pending"|"sold"'
type: string
type: object
Break It Down - Body Annotations
zircote/swagger-php is helping us out quite a bit here. We first tell it that this entire class is a schema definition:
1
2
3
4
5
6
/**
* @OA\Schema(schema="Pet")
*/
class Pet
{
That generates this YAML:
1
2
3
4
components:
schemas:
Pet:
You can change schema to any unique name you want, it is completely separate from the actual class name. We only used the same name for class and schema to make this easier to learn.
With the above, zircote/swagger-php now knows that the class directly following the annotation will contain more annotations that should be added to the definition. That is why this annotation works:
1
2
3
4
5
6
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
The @OA\Property() annotations works in a similar way to the @OA\Schema() annotations - it is attached to the property directly following the annotation. So, the following would not affect $info:
1
2
3
4
5
6
7
8
9
10
11
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
/**
* @var DogInfo|CatInfo|FishInfo
*/
public $info;
You will also notice that any text added in the @var docblock is used as the parameter’s description. We can change this later but keep it for now.
1
2
3
4
type:
description: '"dog"|"cat"|"fish"'
type: string
If you reload your documentation page you will not actually see any of these changes! For that, we need to reference this new Pet schema from the POST /pet path.
Referencing a Schema
Back in PetController::petCreate(), update the path annotation to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @OA\Post(path="/pet",
* summary="Add a new pet",
* operationId="petCreate",
* tags={"Pet"},
* @OA\RequestBody(
* @OA\MediaType(mediaType="multipart/form-data",
* @OA\Schema(ref="#/components/schemas/Pet"),
* ),
* ),
* )
*/
public function petCreate(RequestInterface $request): string
{
Refresh your documentation page and see the magic:
![]() |
|---|
| Your first request body |
Break It Down - RequestBody Annotations
I just introduced two new annotations to you, @OA\RequestBody() and @OA\MediaType().
For an endpoint that expects body data (for example, from forms) we use the @OA\RequestBody() annotation. This annotation should only be combined with @OA\Post() and @OA\Put() (and @OA\Patch() if you ever use it).
@OA\MediaType() defines the content-type this endpoint will accept. For any endpoint that will accept a file upload, you must use multipart/form-data. You can use anything here, but should probably stick with the most common:
application/jsonapplication/xmltext/plain; charset=utf-8
You can see more examples in Swagger’s Media Type page.
So far what we have is
1
2
3
4
5
6
7
8
9
10
11
12
paths:
/pet:
post:
tags:
- Pet
summary: 'Add a new pet'
operationId: petCreate
requestBody:
content:
multipart/form-data:
# ...
Finally, you see the powerful ref for the first time. ref deserves to have a large chunk of text explaining it, but for now let’s keep it simple.
At a Glance - ref
ref is actually $ref when written to the openapi.yaml file. However, our annotations library expects ref. Here is what it looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
paths:
/pet:
post:
tags:
- Pet
summary: 'Add a new pet'
operationId: petCreate
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/Pet'
We use this special tool to reference schema defined in other parts of our definitions file. The first parts are the location OpenAPI can find the schema: #/components/schemas.
The last part is the actual schema name, Pet.
This is powerful because it allows us to reuse schemas without having to copy and paste their definitions over and over.
Enum Property Types
So far we have only added simple property types to our definitions, those that are string, integer, or string::binary (file upload).
The string::binary type clues us in that we can have further sub-types. One such example are enums.
The Pet::$type property is currently defined as
1
2
3
4
5
6
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
However, the "dog"|"cat"|"fish" part is only a docblock description, it does not actually apply to the OpenAPI definition (outside of description).
We can easily tell OpenAPI this is an enum by using string::enum:
1
2
3
4
5
6
7
8
/**
* @var string
* @OA\Property(type="string",
* enum={"dog", "cat", "fish"}
* )
*/
public $type;
Now our definitions file knows this is an enum and will require that the string is only one of the allowed:
1
2
3
4
5
6
7
8
9
10
11
12
13
components:
schemas:
Pet:
properties:
# ...
type:
type: string
enum:
- dog
- cat
- fish
type: object
Do the same for status:
1
2
3
4
5
6
7
8
/**
* @var string
* @OA\Property(type="string",
* enum={"available", "pending", "sold"}
* )
*/
public $status = 'available';
Regenerate your descriptions file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
components:
schemas:
Pet:
properties:
# ...
type:
type: string
enum:
- dog
- cat
- fish
status:
type: string
enum:
- available
- pending
- sold
type: object
Now reload your Redoc browser window and we see something useful:
![]() |
|---|
| Enums example |
But Wait, There’s More
So far we have used zircote/swagger-php for things that we can easily do by manually-creating the definitions file. Let me show you one immediate benefit!
Enums are just arrays of values and are defined as you would expect:
1
2
3
4
5
6
7
8
/**
* @var string
* @OA\Property(type="string",
* enum={"dog", "cat", "fish"}
* )
*/
public $type;
Since the annotations live in a PHP class, we can benefit from actual PHP code in the annotations. If this enum is just an array, why not create a constant that contains the possible values?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* @OA\Schema(schema="Pet")
*/
class Pet
{
public const TYPE_ENUM = [
'dog',
'cat',
'fish',
];
public const STATUS_ENUM = [
'available',
'pending',
'sold',
];
// ...
/**
* @var string
* @OA\Property(type="string",
* enum=\PetStoreApi\Model\Pet::TYPE_ENUM
* )
*/
public $type;
// ...
/**
* @var string
* @OA\Property(type="string",
* enum=\PetStoreApi\Model\Pet::STATUS_ENUM
* )
*/
public $status = 'available';
Note that you need to use the FQDN when reference a constant in this manner.
This is only the most basic of what you can do with constants. As we go further along in this series we will see more useful examples!
Wrapping It Up
We have our first path defined! It even has some (basic) parameters, including a file upload and two enums!
In the next part of this series we will create more paths and their parameters, and we will go over how to add complex parameter types like arrays, objects, and arrays of objects.
So much left to do, but having the Redoc documentation to see our changes as we make them is a great motivator. I encourage you to continue playing with the API and reading both the Swagger OpenAPI Guide and the zircote/swagger-php documentation to further familiarize yourself with the concepts we have learned so far, and what we will learn in the upcoming parts of this series.
Until next time, this is Señor PHP Developer Juan Treminio wishing you adios!


