Hantsy Bai
Posted on November 24, 2021
Symfony is a full-featured modularized PHP framework which is used for building all kinds of applications, from traditional web applications to the small Microservice components.
Get your feet wet
Install PHP 8 and PHP Composer tools.
# choco php composer
Install Symfony CLI, check the system requirements.
# symfony check:requirements
Symfony Requirements Checker
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
> PHP is using the following php.ini file:
C:\tools\php80\php.ini
> Checking Symfony requirements:
....................WWW.........
[OK]
Your system is ready to run Symfony projects
Optional recommendations to improve your setup
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* intl extension should be available
> Install and enable the intl extension (used for validators).
* a PHP accelerator should be installed
> Install and/or enable a PHP accelerator (highly recommended).
* realpath_cache_size should be at least 5M in php.ini
> Setting "realpath_cache_size" to e.g. "5242880" or "5M" in
> php.ini* may improve performance on Windows significantly in some
> cases.
Note The command console can use a different php.ini file
~~~~ than the one used by your web server.
Please check that both the console and the web server
are using the same PHP version and configuration.
According to the recommendations info, adjust your PHP configuration in the php.ini. And we will use Postgres as database in the sample application, make sure pdo_pgsql
and pgsql
modules are enabled.
Finally, you can confirm the enabled modules by the following command.
# php -m
Create a new Symfony project.
# symfony new rest-sample
// a classic website application
# symfony new web-sample --full
By default, it will create a simple Symfony skeleton project only with core kernel configuration, which is good to start a lightweight Restful API application.
Alternatively, you can create it using Composer.
# composer create-project symfony/skeleton rest-sample
//start a classic website application
# composer create-project symfony/website-skeleton web-sample
Enter the generated project root folder, start the application.
# symfony server:start
[WARNING] run "symfony.exe server:ca:install" first if you want to run the web server with TLS support, or use "--no-
tls" to avoid this warning
Tailing PHP-CGI log file (C:\Users\hantsy\.symfony\log\499d60b14521d4842ba7ebfce0861130efe66158\79ca75f9e90b4126a5955a33ea6a41ec5e854698.log)
Tailing Web Server log file (C:\Users\hantsy\.symfony\log\499d60b14521d4842ba7ebfce0861130efe66158.log)
[OK] Web server listening
The Web server is using PHP CGI 8.0.10
http://127.0.0.1:8000
[Web Server ] Oct 4 13:33:01 |DEBUG | PHP Reloading PHP versions
[Web Server ] Oct 4 13:33:01 |DEBUG | PHP Using PHP version 8.0.10 (from default version in $PATH)
[Web Server ] Oct 4 13:33:01 |INFO | PHP listening path="C:\\tools\\php80\\php-cgi.exe" php="8.0.10" port=61738
Hello , Symfony
Create a simple class to a resource entity in the HTTP response.
class Post
{
private ?string $id = null;
private string $title;
private string $content;
//getters and setters.
}
And use a factory to create a new Post instance.
class PostFactory
{
public static function create(string $title, string $content): Post
{
$post = new Post();
$post->setTitle($title);
$post->setContent($content);
return $post;
}
}
Let's create a simple Controller class.
To use the newest PHP 8 attributes to configure the routing rules, apply the following changes in the project configurations.
- Open config/packages/doctrine.yaml, remove
doctrine/orm/mapping/App/type
or change its value toattribute
- Open composer.json, change PHP version to
>=8.0.0
.
To render the response body into a JSON string, use a JsonReponse
to wrap the response.
#[Route(path: "/posts", name: "posts_")]
class PostController
{
#[Route(path: "", name: "all", methods: ["GET"])]
function all(): Response
{
$post1 = PostFactory::create("test title", "test content");
$post1->setId("1");
$post2 = PostFactory::create("test title", "test content");
$post2->setId("2");
$data = [$post1->asArray(), $post2->asArray()];
return new JsonResponse($data, 200, ["Content-Type" => "application/json"]);
//return $this->json($data, 200, ["Content-Type" => "application/json"]);
}
}
The first parameter of JsonReponse
accepts an array as data, so add a function in the Post
class to archive this purpose.
class Post{
//...
public function asArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content
];
}
}
Run the application, use curl
to test the /posts
endpoint.
# curl http://localhost:8000/posts
Symfony provides a simple AbstractController
which includes several functions to simplfy the response and adopt the container and dependency injection management.
In the above controller, extends from AbstractController
, simply call $this->json
to render the response in JSON format, no need to transform the data to an array before rendering response.
class PostController extends AbstractController
{
function all(): Response
{
//...
return $this->json($data, 200, ["Content-Type" => "application/json"]);
}
}
Connecting to Database
Doctrine is a popular ORM framework , it is highly inspired by the existing Java ORM tooling, such as JPA spec and Hibernate framework. There are two core components in Doctrine, doctrine/dbal
and doctrine/orm
, the former is a low level APIs for database operations, if you know Java development, consider it as the Jdbc layer. The later is the advanced ORM framework, the public APIs are similar to JPA/Hibernate.
Install Doctrine into the project.
# composer require symfony/orm-pack
# composer require --dev symfony/maker-bundle
The pack is a virtual Symfony package, it will install a series of packages and basic configurations.
Open the .env
file in the project root folder, edit the DATABASE_URL
value, setup the database name, username, password to connect.
DATABASE_URL="postgresql://user:password@127.0.0.1:5432/blogdb?serverVersion=13&charset=utf8"
Use the following command to generate a docker compose file template.
# php bin/console make:docker:database
We change it to the following to start up a Postgres database in development.
version: "3.5" # specify docker-compose version, v3.5 is compatible with docker 17.12.0+
# Define the services/containers to be run
services:
postgres:
image: postgres:${POSTGRES_VERSION:-13}-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-blogdb}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
POSTGRES_USER: ${POSTGRES_USER:-user}
volumes:
- ./data/blogdb:/var/lib/postgresql/data:rw
- ./pg-initdb.d:/docker-entrypoint-initdb.d
We will use UUID
as data type of the primary key, add a script to enable uuid-ossp
extension in Postgres when it is starting up.
-- file: pg-initdb.d/ini.sql
SET search_path TO public;
DROP EXTENSION IF EXISTS "uuid-ossp";
CREATE EXTENSION "uuid-ossp" SCHEMA public;
Open config/packages/test/doctrine.yaml, comment out dbname_suffix
line. We use Docker container to bootstrap a database to ensure the application behaviors are same between the development and production.
Now startup the application and make sure there is no exception in the console, that means the database connection is successful.
symfony server:start
Before starting the application, make sure the database is running. Run the following command to start up the Postgres in Docker.
# docker compose up postgres
# docker ps -a # to list all containers and make the postgres is running
Building Data Models
Now we will build the Entities that will be used in the next sections. We are modeling a simple blog system, it includes the following concepts.
- A
Post
presents an article post in the blog system. - A
Comment
presents the comments under a specific post. - The common
Tag
can be applied on different posts, which categorizes posts by topic, categories , etc.
You can draft your model relations in mind or through some graphic data modeling tools.
- Post and comments is a
one-to-many
relation - Post and tag is a
many-to-many
relation
It is easy to convert the idea to real codes via Doctrine Entity
. Run the following command to create Post
, Comment
and Tag
entities.
In the Doctrine ORM 2.10.x and Dbal 3.x, the UUID type ID generator is deprecated. We will switch to the Uuid form symfony\uid
.
Install symfony\uid
firstly.
# composer require symfony/uid
Simply, you can use the following command to create entities quickly.
# php bin/console make:entity # following the interactive steps to create them one by one.
Finally we got three entities in the src/Entity folder. Modify them as you expected.
// src/Entity/Post.php
#[Entity(repositoryClass: PostRepository::class)]
class Post
{
#[Id]
//#[GeneratedValue(strategy: "UUID")
//#[Column(type: "string", unique: true)]
#[Column(type: "uuid", unique: true)]
#[GeneratedValue(strategy: "CUSTOM")]
#[CustomIdGenerator(class: UuidGenerator::class)]
private ?Uuid $id = null;
#[Column(type: "string", length: 255)]
private string $title;
#[Column(type: "string", length: 255)]
private string $content;
#[Column(name: "created_at", type: "datetime", nullable: true)]
private DateTime|null $createdAt = null;
#[Column(name: "published_at", type: "datetime", nullable: true)]
private DateTime|null $publishedAt = null;
#[OneToMany(mappedBy: "post", targetEntity: Comment::class, cascade: ['persist', 'merge', "remove"], fetch: 'LAZY', orphanRemoval: true)]
private Collection $comments;
#[ManyToMany(targetEntity: Tag::class, mappedBy: "posts", cascade: ['persist', 'merge'], fetch: 'EAGER')]
private Collection $tags;
public function __construct()
{
$this->createdAt = new DateTime();
$this->comments = new ArrayCollection();
$this->tags = new ArrayCollection();
}
//other getters and setters
}
// src/Entity/Comment.php
#[Entity(repositoryClass: CommentRepository::class)]
class Comment
{
#[Id]
//#[GeneratedValue(strategy: "UUID")]
#[Column(type: "uuid", unique: true)]
#[GeneratedValue(strategy: "CUSTOM")]
#[CustomIdGenerator(class: UuidGenerator::class)]
private ?Uuid $id = null;
#[Column(type: "string", length: 255)]
private string $content;
#[Column(name: "created_at", type: "datetime", nullable: true)]
private DateTime|null $createdAt = null;
#[ManyToOne(targetEntity: "Post", inversedBy: "comments")]
#[JoinColumn(name: "post_id", referencedColumnName: "id")]
private Post $post;
public function __construct()
{
$this->createdAt = new DateTime();
}
//other getters and setters
}
//src/Entity/Tag.php
#[Entity(repositoryClass: TagRepository::class)]
class Tag
{
#[Id]
//#[GeneratedValue(strategy: "UUID")
//#[Column(type: "string", unique: true)]
#[Column(type: "uuid", unique: true)]
#[GeneratedValue(strategy: "CUSTOM")]
#[CustomIdGenerator(class: UuidGenerator::class)]
private ?Uuid $id = null;
#[Column(type: "string", length: 255)]
private ?string $name;
#[ManyToMany(targetEntity: Post::class, inversedBy: "tags")]
private Collection $posts;
public function __construct()
{
$this->posts = new ArrayCollection();
}
}
At the same time, it generated three Repository
classes for these entities.
// src/Repository/PostRepsoitory.php
class PostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
}
// src/Repository/CommentRepsoitory.php
class CommentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Comment::class);
}
}
//src/Repository/TagRepository.php
class TagRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tag::class);
}
}
You can use Doctrine migration to generate a Migration file to maintain database schema in a production environment.
Run the following command to generate a Migration file.
# php bin/console make:migration
After it is executed, a Migration file is generated in the migrations folder, its naming is like Version20211104031420
. It is a simple class extended AbstractMigration
, the up
function is use for upgrade to this version and down
function is use for downgrade to the previous version.
To apply Migrations on database automaticially.
# php bin/console doctrine:migrations:migrate
# return to prev version
# php bin/console doctrine:migrations:migrate prev
# migrate to next
# php bin/console doctrine:migrations:migrate next
# These alias are defined : first, latest, prev, current and next
# certain version fully qualified class name
# php bin/console doctrine:migrations:migrate FQCN
Doctrine bundle also includes some command to maintain database and schema. eg.
# php bin/console doctrine:database:create
# php bin/console doctrine:database:drop
// schema create, drop, update and validate
# php bin/console doctrine:schema:create
# php bin/console doctrine:schema:drop
# php bin/console doctrine:schema:update
# php bin/console doctrine:schema:validate
Adding Sample Data
Create a custom command to load some sample data.
# php bin/console make:command add-post
It will generate a AddPostCommand
under src/Command folder.
#[AsCommand(
name: 'app:add-post',
description: 'Add a short description for your command',
)]
class AddPostCommand extends Command
{
public function __construct(private EntityManagerInterface $manager)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('title', InputArgument::REQUIRED, 'Title of a post')
->addArgument('content', InputArgument::REQUIRED, 'Content of a post')
//->addOption('option1', null, InputOption::VALUE_NONE, 'Option description')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$title = $input->getArgument('title');
if ($title) {
$io->note(sprintf('Title: %s', $title));
}
$content = $input->getArgument('content');
if ($content) {
$io->note(sprintf('Content: %s', $content));
}
$entity = PostFactory::create($title, $content);
$this ->manager->persist($entity);
$this ->manager->flush();
// if ($input->getOption('option1')) {
// // ...
// }
$io->success('Post is saved: '.$entity);
return Command::SUCCESS;
}
}
The Doctrine EntityManagerInterface
is managed by Symfony Service Container, and use for data persistence operations.
Run the following command to add a post into the database.
# php bin/console app:add-post "test title" "test content"
! [NOTE] Title: test title
! [NOTE] Content: test content
[OK] Post is saved: Post: [ id =1ec3d3ec-895d-685a-b712-955865f6c134, title=test title, content=test content, createdAt=1636010040, blishedAt=]
Testing Repository
PHPUnit is the most popular testing framework in PHP world, Symfony integrates PHPUnit tightly.
Run the following command to install PHPUnit and Symfony test-pack. The test-pack will install all essential packages for testing Symfony components and add PHPUnit configuration, such as phpunit.xml.dist.
# composer require --dev phpunit/phpunit symfony/test-pack
An simple test example written in pure PHPUnit.
class PostTest extends TestCase
{
public function testPost()
{
$p = PostFactory::create("tests title", "tests content");
$this->assertEquals("tests title", $p->getTitle());
$this->assertEquals("tests content", $p->getContent());
$this->assertNotNull( $p->getCreatedAt());
}
}
Symfony provides some specific base classes(KernelTestCase
, WebTestCase
, etc.) to simplfy the testing work in a Symfony project.
The following is an example of testing a Repository
- PostRepository
. The KernelTestCase
contains facilities to bootstrap application kernel and provides service container.
class PostRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private PostRepository $postRepository;
protected function setUp(): void
{
//(1) boot the Symfony kernel
$kernel = self::bootKernel();
$this->assertSame('test', $kernel->getEnvironment());
$this->entityManager = $kernel->getContainer()
->get('doctrine')
->getManager();
//(2) use static::getContainer() to access the service container
$container = static::getContainer();
//(3) get PostRepository from container.
$this->postRepository = $container->get(PostRepository::class);
}
protected function tearDown(): void
{
parent::tearDown();
$this->entityManager->close();
}
public function testCreatePost(): void
{
$entity = PostFactory::create("test post", "test content");
$this->entityManager->persist($entity);
$this->entityManager->flush();
$this->assertNotNull($entity->getId());
$byId = $this->postRepository->findOneBy(["id" => $entity->getId()]);
$this->assertEquals("test post", $byId->getTitle());
$this->assertEquals("test content", $byId->getContent());
}
}
In the above codes, in the setUp
function, boot up the application kernel, after it is booted, a test scoped Service Container is available. Then get EntityManagerInterface
and PostRepository
from service container.
In the testCreatePost
function, persists a Post
entity, and find this post by id and verify the title and content fields.
Currently, PHPUnit does not include PHP 8 Attribute support, the testing codes are similar to the legacy JUnit 4 code style.
Creating PostController: Exposing your first Rest API
Similar to other MVC framework, we can expose RESTful APIs via Symfony Controller
component. Follow the REST convention, we are planning to create the following APIs to a blog system.
-
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 headerLocation
, 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. - ...
Run the following command to create a Controller skeleton. Follow the interactive guide to create a controller named PostController
.
# php bin/console make:constroller
Open src/Controller/PostController.php in IDE.
Add Route
attribute on class level and two functions: one for fetching all posts and another for getting single post by ID.
#[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;
}
Testing Controller
As described in the previous sections, to test Controller/API, create a test class to extend WebTestCase
, which provides a plenty of facilities to handle request and assert response.
Run the following command to create a test skeleton.
# php bin/console make:test
Follow the interactive steps to create a test base on WebTestCase
.
class PostControllerTest extends WebTestCase
{
public function testGetAllPosts(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/posts');
$this->assertResponseIsSuccessful();
//
$response = $client->getResponse();
$data = $response->getContent();
//dump($data);
$this->assertStringContainsString("Symfony and PHP", $data);
}
}
If you try to run the test, it will fail. At the moment, there is no any data for testing.
Preparing Data for Testing Purpose
The doctrine/doctrine-fixtures-bundle
is use for populate sample data for testing purpose, and dama/doctrine-test-bundle
ensures the data is restored before evey test is running.
Install doctrine/doctrine-fixtures-bundle
and dama/doctrine-test-bundle
.
composer require --dev doctrine/doctrine-fixtures-bundle dama/doctrine-test-bundle
Create a new Fixture
.
# php bin/console make:fixtures
In the load
fucntion, persist some data for tests.
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$data = PostFactory::create("Building Restful APIs with Symfony and PHP 8", "test content");
$data->addTag(Tag::of( "Symfony"))
->addTag( Tag::of("PHP 8"))
->addComment(Comment::of("test comment 1"))
->addComment(Comment::of("test comment 2"));
$manager->persist($data);
$manager->flush();
}
}
Run the command to load the sample data into database manually.
# php bin/console doctrine:fixtures:load
Add the following extension configuration into the phpunit.xml.dist
, thus the data will be purged and recreated for every test running.
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
Run the following command to execute PostControllerTest.php
.
# php .\vendor\bin\phpunit .\tests\Controller\PostControllerTest.php
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
}
Customzing ArgumentResolver
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
}
Get Post by ID
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.
Customizing ParamConverter
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 requirementsThe
apply
function to perform the conversion. ifsupports
returns false, this conversion step will be skipped.
Creating a 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.
Create a test function to verify in the PostControllerTest
file.
public function testCreatePost(): void
{
$client = static::createClient();
$data = CreatePostDto::of("test title", "test content");
$crawler = $client->request(
'POST',
'/posts',
[],
[],
[],
$this->getContainer()->get('serializer')->serialize($data, 'json')
);
$this->assertResponseIsSuccessful();
$response = $client->getResponse();
$url = $response->headers->get('Location');
//dump($data);
$this->assertNotNull($url);
$this->assertStringStartsWith("/posts/", $url);
}
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
<
[]
Exception Handling
Symfony kernel provides a event machoism to raise an Exception
in Controller
class and handle them in your custom EventListener
or EventSubscriber
.
For example, create a PostNotFoundException
.
class PostNotFoundException extends \RuntimeException
{
public function __construct(Uuid $uuid)
{
parent::__construct("Post #" . $uuid . " was not found");
}
}
Create a EventListener to catch this exception, and handle the exception as expected.
class ExceptionListener implements LoggerAwareInterface
{
private LoggerInterface $logger;
public function __construct()
{
}
public function onKernelException(ExceptionEvent $event)
{
// You get the exception object from the received event
$exception = $event->getThrowable();
$data = ["error" => $exception->getMessage()];
// Customize your response object to display the exception details
$response = new JsonResponse($data);
// HttpExceptionInterface is a special type of exception that
// holds status code and header details
if ($exception instanceof PostNotFoundException) {
$response->setStatusCode(Response::HTTP_NOT_FOUND);
} else if ($exception instanceof HttpExceptionInterface) {
$response->setStatusCode($exception->getStatusCode());
$response->headers->replace($exception->getHeaders());
} else {
$response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
}
// sends the modified response object to the event
$event->setResponse($response);
}
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
}
}
Register this ExceptionListener
in config/service.yml file.
App\EventListener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception, priority: 50 }
It indicates it binds event.exception
event to ExceptionListener
, and set priority
to set the order at execution time.
Run the following command to show all registered EventListener
/EventSubscriber
s on event kernel.exception.
php bin/console debug:event-subscriber kernel.exception
Change the getById
function to the following.
#[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 {
throw new PostNotFoundException($id);
}
}
Add a test to verify if the post is not found and get a 404 status code.
public function testGetANoneExistingPost(): void
{
$client = static::createClient();
$id = Uuid::v4();
$crawler = $client->request('GET', '/posts/' . $id);
//
$response = $client->getResponse();
$this->assertResponseStatusCodeSame(404);
$data = $response->getContent();
$this->assertStringContainsString("Post #" . $id . " was not found", $data);
}
Run the application again, and try to access a single Post through a none existing id.
curl http://localhost:8000/posts/1ec3e1e0-17b3-6ed2-a01c-edecc112b438 -H "Accept: application/json" -v
> GET /posts/1ec3e1e0-17b3-6ed2-a01c-edecc112b438 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.55.1
> Accept: application/json
>
< HTTP/1.1 404 Not Found
< Cache-Control: no-cache, private
< Content-Type: application/json
< Date: Mon, 22 Nov 2021 03:57:51 GMT
< X-Powered-By: PHP/8.0.10
< X-Robots-Tag: noindex
< Content-Length: 69
<
{"error":"Post #1ec3e1e0-17b3-6ed2-a01c-edecc112b438 was not found."}
Get the complete source codes from my Github.
Posted on November 24, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 18, 2024
November 11, 2024