9 Comments // Reading Time: 17 min.
Note: This article is part of a series. The first article discussed the creation of an initial Symfony 2 project, and the second is about Symfony 2 forms.
In this article I will describe a nice and easy way to implement a REST API with the Symfony framework.
What is a REST API?
REST API is a web service which uses REST architecture.
REST = "REpresentational State Transfer"
API = "Application Programming Interface"
REST architecture is resource based which means that it is focused on resources instead on actions meaning that it calls (in URI) a resource (in this tutorial the resource is 'type') and uses http verb (method) to say what operation it wishes to do on the resource.
Rest typically runs over http and has several architectural constraints:
- Decouples consumers from producers
- Stateless existence
- Able to leverage a cache
- Leverages a layered system
- Leverages a uniform interface
1. Install dependencies
A good practice in programing world is not to reinvent the wheel in each project. - Reuse the code. If there is a good code (package) that meets our needs, we should use it. In this project I've used a cool bundle: FOSRest bundle (and 2 more) to help me build REST API.
So, let's include the bundles via composer. Add these lines too "require" section of your composer.json file:
// File: composer.json
"friendsofsymfony/rest-bundle": "@dev",
"jms/serializer-bundle": "@dev",
"nelmio/api-doc-bundle": "@dev"
Or, you can add those bundles via composer require command.
Important notice:
Maybe you noticed that I've used '@dev' versions. Use dev versions only if you don't have choice, or you have a good reason for it. You should use the newest stable version instead.
Don't forget to add the bundles to the AppKernel class:
// File: app/AppKernel.php
public function registerBundles() {
$bundles = [
// ...
new FOS\RestBundle\FOSRestBundle(),
new JMS\SerializerBundle\JMSSerializerBundle(),
new Nelmio\ApiDocBundle\NelmioApiDocBundle(),
];
// ...
2. Create database
You need to set the database parameters in app/config/parameters.yaml, or you can run composer install command to generate the file automatically. In any case, you must provide necessary database parameters.
Then run this command:
php app/console doctrine:database:create
# File: app/config/parameters.yml
database_driver pdo_mysql
database_host: xxx
database_port: xxx
database_name: xxx
database_user: xxx
database_password: xxx
mailer_transport: smtp
mailer_host: xxx
mailer_user: xxx
mailer_password: xxx
locale: de
secret: xxx
3. Let's create our entity
For the purpose of this article, I will present the Type entity and make REST API for it. It is linked with other entities, but they won't be displayed here because complete process of making the Type entity an API can be applied on any other entity.
Type entity class can be created via this interactive command:
php app/console doctrine:generate:entities TypoScriptBackendBundle:Type
Or you can make the php file in Entity directory.
// File: SGalinski/TypoScriptBackendBundle/Entity/Type.php
<?php
namespace SGalinski\TypoScriptBackendBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Type
*
* @ORM\Table(uniqueConstraints={
* @ORM\UniqueConstraint(name="type_unique", columns={"name", "typo3_group"}),
* @ORM\UniqueConstraint(name="url_name_unique", columns={"url_name", "typo3_group"})
* })
* @ORM\Entity(repositoryClass="SGalinski\TypoScriptBackendBundle\Entity\TypeRepository")
*/
class Type {
/**
* Constant used in $typo3Group field.
*/
const NORMAL_GROUP = 1;
/**
* Constant used in $typo3Group field.
*/
const PAGE_GROUP = 2;
/**
* Constant used in $typo3Group field.
*/
const USER_GROUP = 3;
/**
* Default value of property $minVersion. Default value also needs to be specified in property annotations.
*/
const MIN_VERSION_DEFAULT = "4.5";
/**
* @var integer
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255)
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="url_name", type="string", length=255)
*/
private $urlName;
/**
* @var string
*
* @ORM\Column(name="description", type="text", nullable=true)
*/
private $description;
/**
* @var string The TYPO3 version in which the type was introduced
* Example: "4.5" "6.1" "6.2"), NULL is interpreted as most early version.
*
* @Assert\Range(
* min = 4.5,
* minMessage = "Lowest supported version is {{ limit }}.",
* )
* @ORM\Column(name="min_version", type="decimal", precision=4, scale=1, options={"default" = "4.5"}, nullable=false)
*/
private $minVersion = Type::MIN_VERSION_DEFAULT;
/**
* @var string Last TYPO3 version in which the type existed.
* A version after which the type was deprecated.
* Example: "4.5" "6.1" "6.2"), NULL is default and is interpreted as latest version.
*
* @Assert\Range(
* min = 4.5,
* minMessage = "Lowest supported version is {{ limit }}.",
* )
* @ORM\Column(name="max_version", type="decimal", precision=4, scale=1, nullable=true)
*/
private $maxVersion = NULL;
/**
* @var integer 3 = USER_GROUP, 2 = PAGE_GROUP, 1 = NORMAL_GROUP
*
* @ORM\Column(name="typo3_group", type="smallint", options={"default" = 1})
*/
private $typo3Group = Type::NORMAL_GROUP;
/**
* Reference to the Type this Type extends.
*
* @var Type
*
* @ORM\ManyToOne(targetEntity="Type")
* @ORM\JoinColumn(name="extends_id", referencedColumnName="id", onDelete="CASCADE")
*/
private $extends;
/**
* @var Category
*
* @ORM\ManyToOne(targetEntity="Category")
* @ORM\JoinColumn(name="category", referencedColumnName="id")
*/
private $category;
/**
* Properties of the Type
*
* @var ArrayCollection
*
* @ORM\OneToMany(targetEntity="Property", mappedBy="parentType")
*/
private $children;
/**
* @var boolean
*
* @ORM\Column(name="deleted", type="boolean")
*/
private $deleted = FALSE;
/**
* Get id
*
* @return integer
*/
public function getId() {
return $this->id;
}
/**
* Set name
*
* @param string $name
* @return Type
*/
public function setName($name) {
$this->name = $name;
$this->urlName = preg_replace('/[^a-z0-9_-]/is', '_', $name);
return $this;
}
/**
* Get name
*
* @return string
*/
public function getName() {
return $this->name;
}
/**
* Get urlName
*
* @return string
*/
public function getUrlName() {
return $this->urlName;
}
/**
* Set urlName
*
* @param string $name
* @return Type
*/
public function setUrlName($name) {
$this->urlName = $name;
return $this;
}
/**
* Set description
*
* @param string $description
* @return Type
*/
public function setDescription($description) {
$this->description = $description;
return $this;
}
/**
* Get description
*
* @return string
*/
public function getDescription() {
return $this->description;
}
/**
* Set minVersion
*
* @param string $minVersion
* @return Type
*/
public function setMinVersion($minVersion) {
$this->minVersion = $minVersion;
return $this;
}
/**
* Get minVersion
*
* @return string
*/
public function getMinVersion() {
return $this->minVersion;
}
/**
* @return string
*/
public function getMaxVersion() {
return $this->maxVersion;
}
/**
* @param string $maxVersion
* @return Type
*/
public function setMaxVersion($maxVersion) {
$this->maxVersion = $maxVersion;
return $this;
}
/**
* Set typo3Group
*
* @param integer $typo3Group
* @return Type
*/
public function setTypo3Group($typo3Group) {
$this->typo3Group = $typo3Group;
return $this;
}
/**
* Get typo3Group
*
* @return integer
*/
public function getTypo3Group() {
return $this->typo3Group;
}
/**
* Set children
*
* @param ArrayCollection $children
* @return Type
*/
public function setChildren($children) {
$this->children = $children;
return $this;
}
/**
* Get children
*
* @return ArrayCollection
*/
public function getChildren() {
return $this->children;
}
/**
* Adds one child to the list.
*
* @param Property $child
*/
public function addChild(Property $child) {
$this->children[] = $child;
}
/**
* Set deleted
*
* @param boolean $deleted
* @return Type
*/
public function setDeleted($deleted) {
$this->deleted = $deleted;
return $this;
}
/**
* Get deleted
*
* @return boolean
*/
public function getDeleted() {
return $this->deleted;
}
/**
* @return Category
*/
public function getCategory() {
return $this->category;
}
/**
* @param Category $category
* @return Type
*/
public function setCategory($category) {
$this->category = $category;
return $this;
}
/**
* @return Type
*/
public function getExtends() {
return $this->extends;
}
/**
* @param Type $extends
* @return Type
*/
public function setExtends($extends) {
$this->extends = $extends;
return $this;
}
}
Run this command to see generated SQL:
php app/console doctrine:schema:update --dump-sql
If you are happy with produced SQL, run this command to generate the table in database:
php app/console doctrine:schema:update --force
We will also need the TypeRepository class. It is created automatically when you executed the command:
php app/console doctrine:generate:entities TypoScriptBackendBundle:Type
If it is not created, make it 'by hand'.
4. The routes and the FOSRest bundle configuration
Create routes.yaml file in your bundle and include it in the main routing file:
# File: SGalinski/TypoScriptBackendBundle/Resources/config/routes.yml
typo_script_backend_typoscript_type:
resource: "SGalinski\TypoScriptBackendBundle\Controller\Typoscript\TypeController"
type: rest
name_prefix: api_typoscript_
prefix: /typoscript
# File: app/config/routing.yml
typo_script_backend:
resource: "@TypoScriptBackendBundle/Resources/config/routes.yml"
type: rest
prefix: /api
// File: app/config/config.yml
fos_rest:
param_fetcher_listener: true
view:
view_response_listener: 'force'
formats:
xml: true
json: true
templating_formats:
html: true
format_listener:
rules:
- { path: ^/, priorities: [ json, xml, html ], fallback_format: ~, prefer_extension: true }
exception:
codes:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
'SGalinski\TypoScriptBackendBundle\Exception\BadRequestDataException': HTTP_BAD_REQUEST
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
'SGalinski\TypoScriptBackendBundle\Exception\BadRequestDataException': true
allowed_methods_listener: true
access_denied_listener:
json: true
body_listener: true
disable_csrf_role: ROLE_API
5. Symfony form
We will need a way to validate the data submitted by the API. Easy way to do it is to use Symfony forms. So, let's create a basic Symfony form for the Type entity:
// File: SGalinski/TypoScriptBackendBundle/Form/TypeType.php
<?php
namespace SGalinski\TypoScriptBackendBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TypeType extends AbstractType {
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('name')
->add('urlName')
->add('description')
->add('extends')
->add('minVersion')
->add('maxVersion')
->add('typo3Group')
->add('deleted')
->add('category');
}
/**
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver) {
$resolver->setDefaults(
[
'data_class' => 'SGalinski\TypoScriptBackendBundle\Entity\Type',
'csrf_protection' => false,
]
);
}
/**
* @return string
*/
public function getName() {
return 'sgalinski_typoscriptbackendbundle_type';
}
}
6. REST controller
We came to the most important part - implementation of the REST methods.
To seize the power of the FOSRest bundle, our TypeController should extend the class FOS\RestBundle\Controller\FOSRestController:
// File: SGalinski/TypoScriptBackendBundle/Controller/Typoscript/TypeController.php
namespace SGalinski\TypoScriptBackendBundle\Controller\Typoscript;
use FOS\RestBundle\Controller\FOSRestController;
class TypeController extends FOSRestController
Lets add REST methods to the controller.
// File: SGalinski/TypoScriptBackendBundle/Controller/Typoscript/TypeController.php
/**
* REST action which returns type by id.
* Method: GET, url: /api/typoscript/types/{id}.{_format}
*
* @ApiDoc(
* resource = true,
* description = "Gets a Type for a given id",
* output = "SGalinski\TypoScriptBackendBundle\Entity\Type",
* statusCodes = {
* 200 = "Returned when successful",
* 404 = "Returned when the page is not found"
* }
* )
*
* @param $id
* @return mixed
*/
public function getTypeAction($id) {
/** @var TypeRepository $typeRepository */
$typeRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:Type');
$type = NULL;
try {
$type = $typeRepository->find($id);
} catch (\Exception $exception) {
$type = NULL;
}
if (!$type) {
throw new NotFoundHttpException(sprintf('The resource \'%s\' was not found.', $id));
}
return $type;
}
getTypeAction will produce HTTP GET method on the type resource. It can be tested from console (Linux) with curl command:
curl -G 127.0.0.1/app.php/api/typoscript/types/1
By default it returns the Type in JSON format, but XML is also supported, and the format can be explicitly specified:
curl -G 127.0.0.1/app.php/api/typoscript/types/1.xml
So what does this method do? It fetches the entity by ID from the repository (database). If the entity is not found, NotFoundHttpException is thrown. (We will talk about exceptions later in this text) And if the entity is found, it is returned, and FOSRest bundle will convert it automatically in requested format.
// File: // File: SGalinski/TypoScriptBackendBundle/Controller/Typoscript/TypeController.php
/**
* Create a Type from the submitted data.
*
* @ApiDoc(
* resource = true,
* description = "Creates a new type from the submitted data.",
* input = "SGalinski\TypoScriptBackendBundle\Entity\Type",
* statusCodes = {
* 200 = "Returned when successful",
* 400 = "Returned when the form has errors",
* 401 = "Returned when not authenticated",
* 403 = "Returned when not having permissions"
* }
* )
*
* @param Request $request the request object
*
* @return FormTypeInterface|View
*/
public function postTypeAction(Request $request) {
try {
try {
/** @var UserRepository $userRepository */
$userRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:User');
if (!$userRepository->canEditData($request)) {
throw new AccessDeniedException();
}
$persistedType = $this->createNewType($request);
$routeOptions = [
'id' => $persistedType->getId(),
'_format' => $request->get('_format')
];
return $this->routeRedirectView('api_typoscript_get_type', $routeOptions, Codes::HTTP_CREATED);
} catch (InvalidFormException $exception) {
return $exception->getForm();
}
} catch (\Exception $exception) {
$this->throwFosrestSupportedException($exception);
}
}
We use the postTypeAction to create a new Type and persist it to database. It is done within createNewType method:
// File: SGalinski/TypoScriptBackendBundle/Controller/Typoscript/TypeController.php
/**
* Creates new type from request parameters and persists it.
*
* @param Request $request
* @return Type - persisted type
*/
protected function createNewType(Request $request) {
$type = new Type();
$parameters = $request->request->all();
$persistedType = $this->processForm($type, $parameters, 'POST');
return $persistedType;
}
/**
* Processes the form.
*
* @param Type $type
* @param array $parameters
* @param String $method
* @return Type
*
* @throws InvalidFormException
*/
private function processForm(Type $type, array $parameters, $method = 'PUT') {
$form = $this->createForm(new TypeType(), $type, ['method' => $method]);
$form->submit($parameters, 'PATCH' !== $method);
if ($form->isValid()) {
/** @var Type $type */
$type = $form->getData();
/** @var TypeRepository $typeRepository */
$typeRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:Type');
$typeRepository->persistType($type);
return $type;
}
throw new InvalidFormException('Invalid submitted data', $form);
}
As you can see, in the method processForm, we are using previously created Symfony form to validate the request data. If the data is valid, we are persisting it to the database.
// File: SGalinski/TypoScriptBackendBundle/Controller/Typoscript/TypeController.php
/**
* Update existing type from the submitted data or create a new type.
* All required fields must be set within request data.
*
* @ApiDoc(
* resource = true,
* input = "SGalinski\TypoScriptBackendBundle\Entity\Type",
* statusCodes = {
* 201 = "Returned when the Type is created",
* 204 = "Returned when successful",
* 400 = "Returned when the form has errors",
* 401 = "Returned when not authenticated",
* 403 = "Returned when not having permissions"
* }
* )
*
* @param Request $request the request object
* @param int $id the type id
*
* @return FormTypeInterface|View
*
* @throws NotFoundHttpException when type not exist
*/
public function putTypeAction(Request $request, $id) {
try {
try {
/** @var UserRepository $userRepository */
$userRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:User');
if (!$userRepository->canEditData($request)) {
throw new AuthenticationException();
}
/** @var TypeRepository $typeRepository */
$typeRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:Type');
/** @var Type $type */
$type = $typeRepository->find($id);
if (!$type) {
$statusCode = Codes::HTTP_CREATED;
$type = $this->createNewType($request);
} else {
$statusCode = Codes::HTTP_NO_CONTENT;
$type = $this->processForm($type, $request->request->all(), 'PUT');
}
$routeOptions = [
'id' => $type->getId(),
'_format' => $request->get('_format')
];
return $this->routeRedirectView('api_typoscript_get_type', $routeOptions, $statusCode);
} catch (InvalidFormException $exception) {
return $exception->getForm();
}
} catch (\Exception $exception) {
$this->throwFosrestSupportedException($exception);
}
}
If HTTP PUT method is called, we should search the database for the entity by the given ID.
If we find the entity, we apply the request data to it, validate it and persist it if valid. If the data is invalid, an exception is thrown. All that, except the search, is done within processForm method, which is presented in the text above.
If the entity doesn't exist in the database, it's being created, validated and persisted just like the POST method is called.
// File: SGalinski/TypoScriptBackendBundle/Controller/Typoscript/TypeController.php
/**
* REST action which deletes type by id.
* Method: DELETE, url: /api/typoscript/types/{id}.{_format}
*
* @ApiDoc(
* resource = true,
* description = "Deletes a Type for a given id",
* statusCodes = {
* 204 = "Returned when successful",
* 401 = "Returned when not authenticated",
* 403 = "Returned when not having permissions",
* 404 = "Returned when the type is not found"
* }
* )
*
* @param Request $request
* @param $id
* @return mixed
*/
public function deleteTypeAction(Request $request, $id) {
/** @var UserRepository $userRepository */
$userRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:User');
if (!$userRepository->canEditData($request)) {
throw new AuthenticationException();
}
/** @var TypeRepository $typeRepository */
$typeRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:Type');
/** @var Type $type */
$type = $typeRepository->find($id);
if ($type) {
try {
$typeRepository->deleteType($type);
} catch (\Exception $exception) {
$this->throwFosrestSupportedException($exception);
}
} else {
throw new NotFoundHttpException(sprintf('The resource \'%s\' was not found.', $id));
}
}
This method deletes a Type by ID. First, we are trying to fetch the Type from the database. If we find it, we delete it, otherwise an exception is thrown.
// File: SGalinski/TypoScriptBackendBundle/Controller/Typoscript/TypeController.php
/**
* Update existing type from the submitted data.
*
* @ApiDoc(
* resource = true,
* input = "SGalinski\TypoScriptBackendBundle\Entity\Type",
* statusCodes = {
* 204 = "Returned when successful",
* 400 = "Returned when the form has errors",
* 401 = "Returned when not authenticated",
* 403 = "Returned when not having permissions"
* }
* )
*
* @param Request $request the request object
* @param int $id the type id
*
* @return FormTypeInterface|View
*
* @throws NotFoundHttpException when type does not exist
*/
public function patchTypeAction(Request $request, $id) {
try {
try {
/** @var UserRepository $userRepository */
$userRepository = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:User');
if (!$userRepository->canEditData($request)) {
throw new AuthenticationException();
}
/** @var Type $type */
$type = $this->getDoctrine()->getRepository('TypoScriptBackendBundle:Type')->find($id);
if (!$type) {
throw new NotFoundHttpException(sprintf('The resource \'%s\' was not found.', $id));
}
$statusCode = Codes::HTTP_NO_CONTENT;
$type = $this->processForm($type, $request->request->all(), 'PATCH');
$routeOptions = [
'id' => $type->getId(),
'_format' => $request->get('_format')
];
return $this->routeRedirectView('api_typoscript_get_type', $routeOptions, $statusCode);
} catch (InvalidFormException $exception) {
return $exception->getForm();
}
} catch (\Exception $exception) {
$this->throwFosrestSupportedException($exception);
}
}
As you can see, first we get the entity and then we 'patch' it - update some of it's fields. There lies the main difference between PUT and PATCH HTTP methods - PATCH updates only the fields which were submitted in the request data, and other fields stay unchanged. While PUT updates all the fields of the entity. If some fields are omitted in the request data, those fields will be set to NULL in the entity. Also, PUT method can create new entity while PATCH can't.
7. Exceptions
Good handling of exceptions is important because they help you in development by giving you precious feedback, and they show an error to a user if it occurs. FOSRest bundle catches the exceptions thrown from the controller actions and makes http error messages which are returned to the user.
Important!
There is difference in FOSRest bundle behaviour in development and production environments. In development environment, complete error messages from all uncaught exceptions are sent to the user (developer), while in production, only http error codes with their message are sent to the user. For example if there is some database error caused by invalid user data, FOSRest bundle will return http code 500 with error message: 'Internal server error', which is good. In that way sensitive internal information about the system (which might be contained within e.g. database error message) is being protected. But, if the developer wishes to give more descriptive message to the users, then he must register an exception class and associate it with a http error code. Then he will be able to set a custom error message with more details about the error, and yet he will have control over system data which is being presented to the user.
Here is an example of a custom exception class:
// File: SGalinski/TypoScriptBackendBundle/Exception/BadRequestDataException.php
<?php
namespace SGalinski\TypoScriptBackendBundle\Exception;
/**
* Class BadRequestDataException has purpose to pass the error message through FOSRest bundle to the client.
* It should be used for error caused by client's bad input.
*
* @package SGalinski\TypoScriptBackendBundle\Exception
*/
class BadRequestDataException extends \Exception {
}
All custom exceptions must be registered in config.yaml file, what you can see in routes and configuration section of this article.
All exceptions thrown by the system (e.g. doctrine) must be catched, and the useful information must be extracted and presented to the client in a suitable message. That is the job of the throwFosrestSupportedException function which is called from catch blocks of my code. In this example, the function doesn't do anything smart, it just transmits the error message to the BadRequestDataException which is registered in FOSRest bundle configuration and has assigned http error code. So this function lacks the code which should parse the exception and adapt the message for the client.
/**
* Makes response from given exception.
*
* @param \Exception $exception
* @throws BadRequestDataException
*/
protected function throwFosrestSupportedException(\Exception $exception) {
throw new BadRequestDataException($exception->getMessage());
}
On our git repository you can see complete code used in this tutorial. Cheers! :)
Contact us!
We are a digital agency, which is specialized in the development of digital products. Our core topics are websites and portals with TYPO3, eCommerce with Shopware and Android and iOS-Apps. In addition, we deal with many other topics in the field of web development. Feel free to contact us with your concerns!
Comments
Stefan Galinski
at 26.10.2015Thanks Damjan for this extensive tutorial! Thanks Damjan for this extensive tutorial!
Maximilian Mayer
at 26.10.2015Nice Symfony tutorials, Damjan! :-) Nice Symfony tutorials, Damjan! :-)
olivedev
at 05.10.2016This is quite a long and tiring process. It would take a really long time for you to create a simple rest api. You can make it much quicker like shown in this guide on how to create rest api in [...] This is quite a long and tiring process. It would take a really long time for you to create a simple rest api. You can make it much quicker like shown in this guide on how to create rest api in Symfony 3.1: https://www.cloudways.com/blog/rest-api-in-symfony-3-1/
pHzao
at 04.11.2017The best tutorials i have read until now! Good Work! The best tutorials i have read until now! Good Work!
PARDEEP
at 09.03.2018Nice Article ! i am also working restful api with symfony and publish article on that
Iink: https://www.cloudways.com/blog/rest-api-in-symfony-3-1/ Nice Article ! i am also working restful api with symfony and publish article on that
Iink: https://www.cloudways.com/blog/rest-api-in-symfony-3-1/
Lukasz
at 24.11.2018Something less simple - hope documentation is ok :) https://github.com/tulik/symfony-4-rest-api Something less simple - hope documentation is ok :) https://github.com/tulik/symfony-4-rest-api
Stas
at 03.09.2019Just a short note. API - application programming interface Just a short note. API - application programming interface
johan
at 27.06.2020Just to mention that API means "Application Programming Interface" and NOT "APplication Interface" Just to mention that API means "Application Programming Interface" and NOT "APplication Interface"
Stefan Galinski
at 27.06.2020Thanks. I changed that. Thanks. I changed that.