Skip to content

Producing RESTful API

Follow the REST convention, we are going to create the following APIs.

  • GET /posts Get all posts.
  • GET /posts/{id} Get a single post by ID, if not found, return status 404
  • POST /posts Create a new post from request body, add the new post URI to response header Location, and return status 201
  • DELETE /posts/{id} Delete a single post by ID, return status 204. If the post was not found, return status 404 instead.
  • ...

Next let's create a Controller to handle the incoming requests.

Creating PostController

To create a Controller skeleton, run the following command and follow the interactive guide to create a controller named PostController.

# php bin/console make:constroller

Open src/Controller/PostController.php in IDE.

Add a new function to retrieve all posts. To bind the request path to the controller, add a Route attribute on class level and the all function. The former Route will apply to all functions in this controller.

#[Route(path: "/posts", name: "posts_")]
class PostController extends AbstractController
{
    public function __construct(private PostRepository      $posts)
    {
    }

    #[Route(path: "", name: "all", methods: ["GET"])]
    function all(): Response
    {
        $data = $this->posts->findAll();
        return $this->json($data);
    }

}

Start up the application, and try to access the http://localhost:8000/posts, it will throw a circular dependencies exception when rendering the models in JSON view directly.

There are some solutions to avoid this, the simplest is break the bi-direction relations before rendering the JSON view.

Add a Ignore attribute on Comment.post and Tag.posts.

//src/Entity/Comment.php
class Comment
{
    #[Ignore]
    private Post $post;
}

//src/Entity/Tag.php
class Tag
{
    #[Ignore]
    private Collection $posts;
}

Alternatively, the DTO pattern is a good option to transform the data to a plain object that only includes essential fields before rendering in the HTTP response.

Paginating Result

There are a lot of web applications which provide a input field for typing keyword and paginating the search results. Assume there is a keyword provided by request to match Post title or content fields, a offset to set the offset position of the pagination, and a limit to set the limited size of the elements per page. Create a function in the PostRepository, accepts a keyword, offset and limit as arguments.

public function findByKeyword(string $q, int $offset = 0, int $limit = 20): Page
{
    $query = $this->createQueryBuilder("p")
        ->andWhere("p.title like :q or p.content like :q")
        ->setParameter('q', "%" . $q . "%")
        ->orderBy('p.createdAt', 'DESC')
        ->setMaxResults($limit)
        ->setFirstResult($offset)
        ->getQuery();

    $paginator = new Paginator($query, $fetchJoinCollection = false);
    $c = count($paginator);
    $content = new ArrayCollection();
    foreach ($paginator as $post) {
        $content->add(PostSummaryDto::of($post->getId(), $post->getTitle()));
    }
    return Page::of ($content, $c, $offset, $limit);
}

Firstly, create a dynamic query using createQueryBuilder , then create a Doctrine Paginator instance to execute the query. The Paginator implements Countable interface, use count to get the count of total elements. Finally, we use a custom Page object to wrap the result.

class Page
{
    private Collection $content;
    private int $totalElements;
    private int $offset;
    private int $limit;

    #[Pure] public function __construct()
    {
        $this->content = new ArrayCollection();
    }


    public static function of(Collection $content, int $totalElements, int $offset = 0, int $limit = 20): Page
    {
        $page = new Page();
        $page->setContent($content)
            ->setTotalElements($totalElements)
            ->setOffset($offset)
            ->setLimit($limit);

        return $page;
    }

    //
    //getters

}    

Handling Query Parameters

In the PostController , let's improve the the function which serves the route /posts, make it accept query parameters like /posts?q=Symfony&offset=0&limit=10, and ensure the parameters are optional.

    #[Route(path: "", name: "all", methods: ["GET"])]
    function all(Request $req): Response
    {
        $keyword = $req->query->get('q')??'';
        $offset = $req->query->get('offset')??0;
        $limit = $req->query->get('limit')??10;

        $data = $this->posts->findByKeyword($keyword, $offset, $limit);
        return $this->json($data);
    }

It works but the query parameters handling looks a little ugly. It is great if they can be handled as the route path parameters.

We can create a custom ArgumentResolver to resolve the bound query arguments.

Firstly create an Annotation/Attribute class to identify a query parameter that need to be resolved by this ArgumentResolver.

#[Attribute(Attribute::TARGET_PARAMETER)]
final class QueryParam
{
    private null|string $name;
    private bool $required;

    /**
     * @param string|null $name
     * @param bool $required
     */
    public function __construct(?string $name = null, bool $required = false)
    {
        $this->name = $name;
        $this->required = $required;
    }

    //getters and setters

}    

Create a custom ArgumentResolver implements the built-in ArgugmentResolverInterface.

class QueryParamValueResolver implements ArgumentValueResolverInterface, LoggerAwareInterface
{
    public function __construct()
    {
    }

    private LoggerInterface $logger;

    /**
     * @inheritDoc
     */
    public function resolve(Request $request, ArgumentMetadata $argument)
    {
        $argumentName = $argument->getName();
        $this->logger->info("Found [QueryParam] annotation/attribute on argument '" . $argumentName . "', applying [QueryParamValueResolver]");
        $type = $argument->getType();
        $nullable = $argument->isNullable();
        $this->logger->debug("The method argument type: '" . $type . "' and nullable: '" . $nullable . "'");

        //read name property from QueryParam
        $attr = $argument->getAttributes(QueryParam::class)[0];// `QueryParam` is not repeatable
        $this->logger->debug("QueryParam:" . $attr);
        //if name property is not set in `QueryParam`, use the argument name instead.
        $name = $attr->getName() ?? $argumentName;
        $required = $attr->isRequired() ?? false;
        $this->logger->debug("Polished QueryParam values: name='" . $name . "', required='" . $required . "'");

        //fetch query name from request
        $value = $request->query->get($name);
        $this->logger->debug("The request query parameter value: '" . $value . "'");

        //if default value is set and query param value is not set, use default value instead.
        if (!$value && $argument->hasDefaultValue()) {
            $value = $argument->getDefaultValue();
            $this->logger->debug("After set default value: '" . $value . "'");
        }

        if ($required && !$value) {
            throw new \InvalidArgumentException("Request query parameter '" . $name . "' is required, but not set.");
        }

        $this->logger->debug("final resolved value: '" . $value . "'");

        //must return  a `yield` clause
        yield match ($type) {
            'int' => $value ? (int)$value : 0,
            'float' => $value ? (float)$value : .0,
            'bool' => (bool)$value,
            'string' => $value ? (string)$value : ($nullable ? null : ''),
            null => null
        };
    }

    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        $attrs = $argument->getAttributes(QueryParam::class);
        return count($attrs) > 0;
    }

    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }
}

At runtime, it calls the supports function to check it the current request satisfy the requirement, if it is ok, then invoke the resovle funtion.

In the supports function, we check if the argument is annotated with a QueryParam, if it is existed, then resolved the argument from request query string.

Now change the function that serves /posts endpoint to the following.

#[Route(path: "", name: "all", methods: ["GET"])]
function all(#[QueryParam] $keyword,
    #[QueryParam] int $offset = 0,
    #[QueryParam] int $limit = 20): Response
    {
        $data = $this->posts->findByKeyword($keyword || '', $offset, $limit);
        return $this->json($data);
    }

Run the application and test the /posts using curl.

# curl http://localhost:8000/posts
{
    "content":[
        {
            "id":"1ec3e1e0-17b3-6ed2-a01c-edecc112b436",
            "title":"Building Restful APIs with Symfony and PHP 8"
        }
    ],
    "totalElements":1,
    "offset":0,
    "limit":20
}

Retrieving Post

Follow the design in the previous section, add another function to PostController to map route /posts/{id} .

class PostController extends AbstractController
{
    //other functions...

    #[Route(path: "/{id}", name: "byId", methods: ["GET"])]
    function getById(Uuid $id): Response
    {
        $data = $this->posts->findOneBy(["id" => $id]);
        if ($data) {
            return $this->json($data);
        } else {
            return $this->json(["error" => "Post was not found by id:" . $id], 404);
        }
    }
}

Run the application, and try to access http://localhost:8000/posts/{id}, it will throw an exception like this.

App\Controller\PostController::getById(): Argument #1 ($id) must be of type Symfony\Component\Uid\Uuid, string given, cal
led in D:\hantsylabs\symfony5-sample\rest-sample\vendor\symfony\http-kernel\HttpKernel.php on line 156

The id in the URI is a string, can not be used as Uuid directly.

Symfony provides ParamConverter to convert the request attributes to the target type. We can create a custom ParamConverter to archive the purpose.

Converting Request Attributes

Create a new class UuidParamCovnerter under src/Request/ folder.

class UuidParamConverter implements ParamConverterInterface
{
    public function __construct(private LoggerInterface $logger)
    {
    }


    /**
     * @inheritDoc
     */
    public function apply(Request $request, ParamConverter $configuration): bool
    {

        $param = $configuration->getName();

        if (!$request->attributes->has($param)) {
            return false;
        }

        $value = $request->attributes->get($param);
        $this->logger->info("parameter value:" . $value);
        if (!$value && $configuration->isOptional()) {
            $request->attributes->set($param, null);

            return true;
        }

        $data = Uuid::fromString($value);
        $request->attributes->set($param, $data);

        return true;
    }

    /**
     * @inheritDoc
     */
    public function supports(ParamConverter $configuration): bool
    {
        $className = $configuration->getClass();
        $this->logger->info("converting to UUID :{c}", ["c" => $className]);
        return $className && $className == Uuid::class;
    }
}

In the above codes,

  • The supports function to check the execution environment if matching the requirements

  • The apply function to perform the conversion. if supports returns false, this conversion step will be skipped.

Creating Post

Follow the REST convention, define the following rule to serve an endpoint to handle the request.

  • Request matches Http verbs/HTTP Method: POST
  • Request matches route endpoint: /posts
  • Set request header Content-Type value to application/json, and use request body to hold request data as JSON format
  • If successful, return a CREATED(201) Http Status code, and set the response header Location value to the URI of the new created post.
#[Route(path: "", name: "create", methods: ["POST"])]
public function create(Request $request): Response
{
    $data = $this->serializer->deserialize($request->getContent(), CreatePostDto::class, 'json');
    $entity = PostFactory::create($data->getTitle(), $data->getContent());
    $this->posts->getEntityManager()->persist($entity);

    return $this->json([], 201, ["Location" => "/posts/" . $entity->getId()]);
}

The posts->getEntityManager() overrides parent methods to get a EntityManager from parent class, you can also inject ObjectManager or EntityManagerInterface in the PostController directly to do the persistence work. The Doctrine Repository is mainly designated to build query criteria and execute custom queries.

Converting Request Body

We can also use an Annotation/Attribute to erase the raw codes of handling Request object through introducing a custom ArgumentResolver.

Create a Body Attribute.

#[Attribute(Attribute::TARGET_PARAMETER)]
final class Body
{
}

Then create a BodyValueResolver.

class BodyValueResolver implements ArgumentValueResolverInterface, LoggerAwareInterface
{
    public function __construct(private SerializerInterface $serializer)
    {
    }

    private LoggerInterface $logger;

    /**
     * @inheritDoc
     */
    public function resolve(Request $request, ArgumentMetadata $argument)
    {
        $type = $argument->getType();
        $this->logger->debug("The argument type:'" . $type . "'");
        $format = $request->getContentType() ?? 'json';
        $this->logger->debug("The request format:'" . $format . "'");

        //read request body
        $content = $request->getContent();
        $data = $this->serializer->deserialize($content, $type, $format);
       // $this->logger->debug("deserialized data:{0}", [$data]);
        yield $data;
    }

    /**
     * @inheritDoc
     */
    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        $attrs = $argument->getAttributes(Body::class);
        return count($attrs) > 0;
    }

    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

In the supports method, it simply detects if the method argument annotated with a Body attribute, then apply resolve method to deserialize the request body content to a typed object.

Run the application and test the endpoint through /posts.

curl -v http://localhost:8000/posts -H "Content-Type:application/json" -d "{\"title\":\"test title\",\"content\":\"test content\"}"
> POST /posts HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.55.1
> Accept: */*
> Content-Type:application/json
> Content-Length: 47
>
< HTTP/1.1 201 Created
< Cache-Control: no-cache, private
< Content-Type: application/json
< Date: Sun, 21 Nov 2021 08:42:49 GMT
< Location: /posts/1ec4aa70-1b21-6bce-93f8-b39330fe328e
< X-Powered-By: PHP/8.0.10
< X-Robots-Tag: noindex
< Content-Length: 2
<
[]

Validating Request

In the last section, we convert the request body into an plain object. To validate the object, generally we can inject a Validator service.

__constructor(ValidatorInterface $validator, ...){}

Then invoke validate function to validate the target value, store the validation result into an errors object, you can process it later.

$errors = $validator->validate($body);

if (count($errors) > 0) {
    //...
}

Like the above section, you can create a custom ArgumentValueResolver and a specific Attribute to handle the validation automatically.

But I hope the official validation attributes can be applied on the controller method arguments directly, like the existing Bean Validation in a Spring Controller. For example,

#[Route(path: "", name: "all", methods: ["GET"])]
function all(string $keyword, #[PositiveOrZero] int $offset = 0, #[Positive] int $limit = 20): Response
{
    //...
}

#[Route(path: "", name: "all", methods: ["POST"])]
function create(#[Body] #[Valid] data: CreatePostCommand): Response
{
    //...
}             

Please vote issue #43958 if you like include this feature.

Updating Post

Follow the REST convention, define the following rule to serve an endpoint to handle the request.

  • Request matches Http verbs/HTTP Method: PUT
  • Request matches route endpoint: /posts/{id}
  • If successful, return a NO_CONTENT(204) Http Status code and an empty response body.
#[Route(path: "/{id}", name: "update", methods: ["PUT"])]
public function update(Uuid $id, #[Body] UpdatePostDto $data): Response
{
    $entity = $this->posts->findOneBy(["id" => $id]);
    if (!$entity) {
        throw new PostNotFoundException($id);
        //return $this->json(["error" => "Post was not found by id:" . $id], 404);
    }
    $entity->setTitle($data->getTitle())
        ->setContent($data->getContent());
    $this->objectManager->merge($entity);
    $this->objectManager->flush();

    return $this->json([], 204);
}

Firstly we retrieve the existing post through the id path variable. Update the existing post with data from the request body, and save it back to the database.

Updating Post Status

In the above update operation, we do not update the status field. In a real world application, in some cases we could update a single field instead of the world entity.

The status field of a Post can be updated via a standalone endpoint.

Follow the REST convention, define the following rule to serve an endpoint to handle the request.

  • Request matches Http verbs/HTTP Method: PUT
  • Request matches route endpoint: /posts/{id}/status
  • If successful, return a NO_CONTENT(204) Http Status code and an empty response body.
#[Route(path: "/{id}/status", name: "update_status", methods: ["PUT"])]
public function updateStatus(Uuid $id, #[Body] UpdatePostStatusDto $data): Response
{
    $entity = $this->posts->findOneBy(["id" => $id]);
    if (!$entity) {
        throw new PostNotFoundException($id);
        //return $this->json(["error" => "Post was not found by id:" . $id], 404);
    }
    echo "update post status::::" . PHP_EOL;
    var_export($data);
    $entity->setStatus($data->getStatus());
    $this->objectManager->merge($entity);
    $this->objectManager->flush();

    return $this->json([], 204);
}

Deleting Post

Follow the REST convention, define the following rule to serve an endpoint to handle the request.

  • Request matches Http verbs/HTTP Method: DELETE
  • Request matches route endpoint: /posts/{id}
  • If successful, return a NO_CONTENT(204) Http Status code and an empty response body.
#[Route(path: "/{id}", name: "delete", methods: ["DELETE"])]
public function deleteById(Uuid $id): Response
{
    $entity = $this->posts->findOneBy(["id" => $id]);
    if (!$entity) {
        throw new PostNotFoundException($id);
        //return $this->json(["error" => "Post was not found by id:" . $id], 404);
    }
    $this->objectManager->remove($entity);
    $this->objectManager->flush();

    return $this->json([], 204);
}