Symfony 8 CRUD REST API with Services, PHPUnit and Mocks
We’ll build a REST API using Symfony 8, Doctrine ORM, MySQL, PHPUnit, and a Service layer.
Unlike the previous example, the controller will only handle HTTP requests and responses. All CRUD operations and business logic will be implemented in a dedicated ProductService.
We’ll also use PHPUnit mocks to test the controller and service independently, without requiring a database connection.
1. API endpoints
| Method | Endpoint | Action | Success status |
|---|---|---|---|
| GET | /api/products |
List all products | 200 OK |
| GET | /api/products/{id} |
Get a product | 200 OK |
| POST | /api/products |
Create a product | 201 Created |
| PUT | /api/products/{id} |
Update a product | 200 OK |
| DELETE | /api/products/{id} |
Delete a product | 204 No Content |
2. Application architecture
ProductController
HTTP routing, JSON requests, responses and status codes
|
v
ProductService
Validation, business logic and CRUD operations
|
v
ProductRepository + EntityManager
Doctrine queries and persistence
|
v
MySQL
The project structure will be:
product-api/
├── config/
│ └── routes.yaml
├── src/
│ ├── Controller/
│ │ └── Api/
│ │ └── ProductController.php
│ ├── Entity/
│ │ └── Product.php
│ ├── Exception/
│ │ ├── ProductNotFoundException.php
│ │ └── ProductValidationException.php
│ ├── Repository/
│ │ └── ProductRepository.php
│ └── Service/
│ └── ProductService.php
├── tests/
│ ├── Unit/
│ │ ├── Controller/
│ │ │ └── ProductControllerTest.php
│ │ └── Service/
│ │ └── ProductServiceTest.php
│ └── Functional/
│ └── ProductApiTest.php
├── .env
└── phpunit.dist.xml
3. Install Symfony and dependencies
Create a Symfony 8 project:
symfony new product-api --version="8.0"
cd product-api
Install Doctrine, Validator and testing dependencies:
composer require symfony/framework-bundle
composer require symfony/orm-pack
composer require symfony/validator
composer require --dev symfony/test-pack
composer require --dev symfony/maker-bundle
Configure the database in .env.local:
APP_ENV=dev
APP_DEBUG=1
DATABASE_URL="mysql://root:password@127.0.0.1:3306/product_api?serverVersion=8.0.36&charset=utf8mb4"
Replace the credentials and server version with your actual MySQL configuration.
4. Create the Product entity
src/Entity/Product.php
<?php
namespace App\Entity;
use App\Repository\ProductRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ORM\Table(name: 'product')]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name = '';
#[ORM\Column]
private float $price = 0.0;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getPrice(): float
{
return $this->price;
}
public function setPrice(float $price): self
{
$this->price = $price;
return $this;
}
}
For financial applications, use a decimal representation rather than float to avoid floating-point precision problems.
5. Create the repository
src/Repository/ProductRepository.php
<?php
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
}
Doctrine provides find(), findAll(), and other standard repository methods.
6. Create custom exceptions
We will use exceptions to communicate business errors from the service to the controller.
src/Exception/ProductNotFoundException.php
<?php
namespace App\Exception;
class ProductNotFoundException extends \RuntimeException
{
public function __construct(int $id)
{
parent::__construct("Product with ID {$id} not found");
}
}
src/Exception/ProductValidationException.php
<?php
namespace App\Exception;
class ProductValidationException extends \InvalidArgumentException
{
}
The service throws these exceptions, and the controller converts them into HTTP responses.
This keeps HTTP-specific behavior out of the service.
7. Create the ProductService
This is the main difference from the previous implementation.
The service is responsible for:
- Retrieving products from the repository.
- Creating and updating products.
- Validating product data.
- Persisting and deleting products.
- Throwing exceptions when a product doesn’t exist.
It does not know anything about HTTP requests, JSON responses, or HTTP status codes.
src/Service/ProductService.php
<?php
namespace App\Service;
use App\Entity\Product;
use App\Exception\ProductNotFoundException;
use App\Exception\ProductValidationException;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
class ProductService
{
public function __construct(
private ProductRepository $repository,
private EntityManagerInterface $entityManager
) {
}
/**
* @return Product[]
*/
public function getAll(): array
{
return $this->repository->findAll();
}
public function getById(int $id): Product
{
$product = $this->repository->find($id);
if ($product === null) {
throw new ProductNotFoundException($id);
}
return $product;
}
public function create(array $data): Product
{
$this->validate($data);
$product = new Product();
$product->setName(trim($data['name']));
$product->setPrice((float) $data['price']);
$this->entityManager->persist($product);
$this->entityManager->flush();
return $product;
}
public function update(int $id, array $data): Product
{
$product = $this->getById($id);
$this->validate($data);
$product->setName(trim($data['name']));
$product->setPrice((float) $data['price']);
$this->entityManager->flush();
return $product;
}
public function delete(int $id): void
{
$product = $this->getById($id);
$this->entityManager->remove($product);
$this->entityManager->flush();
}
private function validate(array $data): void
{
if (
!isset($data['name']) ||
!is_string($data['name']) ||
trim($data['name']) === ''
) {
throw new ProductValidationException(
'Name is required'
);
}
if (mb_strlen(trim($data['name'])) > 255) {
throw new ProductValidationException(
'Name must not exceed 255 characters'
);
}
if (
!isset($data['price']) ||
(!is_int($data['price']) &&
!is_float($data['price'])) ||
!is_finite((float) $data['price']) ||
$data['price'] < 0
) {
throw new ProductValidationException(
'Price must be a non-negative number'
);
}
}
}
Why this design is better
For example, when creating a product:
$product = $productService->create([
'name' => 'Laptop',
'price' => 999.99
]);
The service performs validation, creates the entity, and saves it to the database.
The same method can be called from a REST controller, Symfony console command, message handler, or another service without duplicating business logic.
8. Create the REST API controller
The controller now has only one dependency: ProductService.
It handles HTTP-specific responsibilities:
- Reading JSON request bodies.
- Calling the service.
- Serializing results to JSON.
- Converting business exceptions into HTTP errors.
src/Controller/Api/ProductController.php
<?php
namespace App\Controller\Api;
use App\Entity\Product;
use App\Exception\ProductNotFoundException;
use App\Exception\ProductValidationException;
use App\Service\ProductService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/products')]
class ProductController
{
public function __construct(
private ProductService $productService
) {
}
#[Route('', methods: ['GET'])]
public function index(): JsonResponse
{
$products = $this->productService->getAll();
return new JsonResponse(
array_map(
fn(Product $product) => $this->serialize($product),
$products
)
);
}
#[Route(
'/{id}',
requirements: ['id' => '\d+'],
methods: ['GET']
)]
public function show(int $id): JsonResponse
{
try {
$product = $this->productService->getById($id);
return new JsonResponse(
$this->serialize($product)
);
} catch (ProductNotFoundException $e) {
return $this->error($e->getMessage(), 404);
}
}
#[Route('', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$data = $this->parseJson($request);
if ($data === null) {
return $this->error('Invalid JSON object', 400);
}
try {
$product = $this->productService->create($data);
$response = new JsonResponse(
$this->serialize($product),
Response::HTTP_CREATED
);
if ($product->getId() !== null) {
$response->headers->set(
'Location',
'/api/products/' . $product->getId()
);
}
return $response;
} catch (ProductValidationException $e) {
return $this->error($e->getMessage(), 422);
}
}
#[Route(
'/{id}',
requirements: ['id' => '\d+'],
methods: ['PUT']
)]
public function update(
int $id,
Request $request
): JsonResponse {
$data = $this->parseJson($request);
if ($data === null) {
return $this->error('Invalid JSON object', 400);
}
try {
$product = $this->productService->update($id, $data);
return new JsonResponse(
$this->serialize($product)
);
} catch (ProductNotFoundException $e) {
return $this->error($e->getMessage(), 404);
} catch (ProductValidationException $e) {
return $this->error($e->getMessage(), 422);
}
}
#[Route(
'/{id}',
requirements: ['id' => '\d+'],
methods: ['DELETE']
)]
public function delete(int $id): Response
{
try {
$this->productService->delete($id);
return new Response(
'',
Response::HTTP_NO_CONTENT
);
} catch (ProductNotFoundException $e) {
return $this->error($e->getMessage(), 404);
}
}
private function parseJson(Request $request): ?array
{
try {
$data = json_decode(
$request->getContent(),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
return null;
}
if (
!is_array($data) ||
array_is_list($data)
) {
return null;
}
return $data;
}
private function serialize(Product $product): array
{
return [
'id' => $product->getId(),
'name' => $product->getName(),
'price' => $product->getPrice(),
];
}
private function error(
string $message,
int $status
): JsonResponse {
return new JsonResponse(
['error' => $message],
$status
);
}
}
Notice that the controller never accesses ProductRepository or EntityManagerInterface directly.
9. Configure Symfony routing and services
Symfony normally autowires classes under src/ automatically.
Verify that config/services.yaml contains:
parameters:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
The service will automatically receive its repository and entity manager.
Configure attribute routing in config/routes.yaml:
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
Create the database and tables:
php bin/console doctrine:database:create
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Verify the registered endpoints:
php bin/console debug:router
10. PHPUnit tests for ProductService with mocks
We’ll first test the business logic independently.
The service depends on two objects:
ProductRepositoryEntityManagerInterface
We’ll mock both using PHPUnit.
These tests verify the behavior of the service without running SQL queries.
tests/Unit/Service/ProductServiceTest.php
<?php
namespace App\Tests\Unit\Service;
use App\Entity\Product;
use App\Exception\ProductNotFoundException;
use App\Exception\ProductValidationException;
use App\Repository\ProductRepository;
use App\Service\ProductService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class ProductServiceTest extends TestCase
{
private ProductRepository $repository;
private EntityManagerInterface $entityManager;
private ProductService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(
ProductRepository::class
);
$this->entityManager = $this->createMock(
EntityManagerInterface::class
);
$this->service = new ProductService(
$this->repository,
$this->entityManager
);
}
private function createProduct(): Product
{
return (new Product())
->setName('Laptop')
->setPrice(999.99);
}
public function testGetAllProducts(): void
{
$products = [
$this->createProduct(),
(new Product())
->setName('Mouse')
->setPrice(29.99)
];
$this->repository
->expects($this->once())
->method('findAll')
->willReturn($products);
$result = $this->service->getAll();
$this->assertCount(2, $result);
$this->assertSame($products, $result);
}
public function testGetProductById(): void
{
$product = $this->createProduct();
$this->repository
->expects($this->once())
->method('find')
->with(1)
->willReturn($product);
$result = $this->service->getById(1);
$this->assertSame($product, $result);
}
public function testGetProductByIdNotFound(): void
{
$this->repository
->expects($this->once())
->method('find')
->with(999)
->willReturn(null);
$this->expectException(
ProductNotFoundException::class
);
$this->service->getById(999);
}
public function testCreateProduct(): void
{
$data = [
'name' => 'Keyboard',
'price' => 49.99
];
$this->entityManager
->expects($this->once())
->method('persist')
->with($this->callback(
fn($product) =>
$product instanceof Product &&
$product->getName() === 'Keyboard' &&
$product->getPrice() === 49.99
));
$this->entityManager
->expects($this->once())
->method('flush');
$product = $this->service->create($data);
$this->assertInstanceOf(Product::class, $product);
$this->assertSame('Keyboard', $product->getName());
$this->assertSame(49.99, $product->getPrice());
}
public function testCreateProductTrimsName(): void
{
$this->entityManager
->expects($this->once())
->method('persist');
$this->entityManager
->expects($this->once())
->method('flush');
$product = $this->service->create([
'name' => ' Laptop ',
'price' => 999.99
]);
$this->assertSame('Laptop', $product->getName());
}
public function testCreateProductWithInvalidName(): void
{
$this->entityManager
->expects($this->never())
->method('persist');
$this->entityManager
->expects($this->never())
->method('flush');
$this->expectException(
ProductValidationException::class
);
$this->service->create([
'name' => '',
'price' => 100
]);
}
public function testCreateProductWithNegativePrice(): void
{
$this->entityManager
->expects($this->never())
->method('persist');
$this->expectException(
ProductValidationException::class
);
$this->service->create([
'name' => 'Laptop',
'price' => -100
]);
}
public function testUpdateProduct(): void
{
$product = $this->createProduct();
$this->repository
->expects($this->once())
->method('find')
->with(1)
->willReturn($product);
$this->entityManager
->expects($this->once())
->method('flush');
$this->entityManager
->expects($this->never())
->method('persist');
$result = $this->service->update(1, [
'name' => 'Gaming Laptop',
'price' => 1299.99
]);
$this->assertSame($product, $result);
$this->assertSame('Gaming Laptop', $result->getName());
$this->assertSame(1299.99, $result->getPrice());
}
public function testUpdateProductNotFound(): void
{
$this->repository
->expects($this->once())
->method('find')
->with(999)
->willReturn(null);
$this->entityManager
->expects($this->never())
->method('flush');
$this->expectException(
ProductNotFoundException::class
);
$this->service->update(999, [
'name' => 'Laptop',
'price' => 100
]);
}
public function testDeleteProduct(): void
{
$product = $this->createProduct();
$this->repository
->expects($this->once())
->method('find')
->with(1)
->willReturn($product);
$this->entityManager
->expects($this->once())
->method('remove')
->with($this->identicalTo($product));
$this->entityManager
->expects($this->once())
->method('flush');
$this->service->delete(1);
}
public function testDeleteProductNotFound(): void
{
$this->repository
->expects($this->once())
->method('find')
->with(999)
->willReturn(null);
$this->entityManager
->expects($this->never())
->method('remove');
$this->entityManager
->expects($this->never())
->method('flush');
$this->expectException(
ProductNotFoundException::class
);
$this->service->delete(999);
}
}
These 11 tests cover the main service operations and important failure cases.
11. PHPUnit tests for ProductController with mocks
Next, we’ll test the HTTP layer.
This time we mock ProductService, not the repository or entity manager.
The controller tests should verify that:
- The correct service method is called.
- The controller passes the correct arguments.
- Successful results are serialized correctly.
- Business exceptions become appropriate HTTP status codes.
- Invalid JSON is rejected before calling the service.
tests/Unit/Controller/ProductControllerTest.php
<?php
namespace App\Tests\Unit\Controller;
use App\Controller\Api\ProductController;
use App\Entity\Product;
use App\Exception\ProductNotFoundException;
use App\Exception\ProductValidationException;
use App\Service\ProductService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
class ProductControllerTest extends TestCase
{
private ProductService $service;
private ProductController $controller;
protected function setUp(): void
{
$this->service = $this->createMock(
ProductService::class
);
$this->controller = new ProductController(
$this->service
);
}
private function createProduct(): Product
{
return (new Product())
->setName('Laptop')
->setPrice(999.99);
}
public function testIndexReturnsProducts(): void
{
$products = [
$this->createProduct(),
(new Product())
->setName('Mouse')
->setPrice(29.99)
];
$this->service
->expects($this->once())
->method('getAll')
->willReturn($products);
$response = $this->controller->index();
$this->assertSame(200, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
$this->assertCount(2, $data);
$this->assertSame('Laptop', $data[0]['name']);
$this->assertSame('Mouse', $data[1]['name']);
}
public function testShowReturnsProduct(): void
{
$this->service
->expects($this->once())
->method('getById')
->with(1)
->willReturn($this->createProduct());
$response = $this->controller->show(1);
$this->assertSame(200, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
$this->assertSame('Laptop', $data['name']);
$this->assertSame(999.99, $data['price']);
}
public function testShowReturns404(): void
{
$this->service
->expects($this->once())
->method('getById')
->with(999)
->willThrowException(
new ProductNotFoundException(999)
);
$response = $this->controller->show(999);
$this->assertSame(404, $response->getStatusCode());
}
public function testCreateReturns201(): void
{
$data = [
'name' => 'Laptop',
'price' => 999.99
];
$request = new Request(
content: json_encode($data)
);
$this->service
->expects($this->once())
->method('create')
->with($data)
->willReturn($this->createProduct());
$response = $this->controller->create($request);
$this->assertSame(201, $response->getStatusCode());
$result = json_decode($response->getContent(), true);
$this->assertSame('Laptop', $result['name']);
$this->assertSame(999.99, $result['price']);
}
public function testCreateReturns422(): void
{
$data = [
'name' => '',
'price' => -10
];
$this->service
->expects($this->once())
->method('create')
->with($data)
->willThrowException(
new ProductValidationException(
'Name is required'
)
);
$request = new Request(
content: json_encode($data)
);
$response = $this->controller->create($request);
$this->assertSame(422, $response->getStatusCode());
$this->assertSame(
['error' => 'Name is required'],
json_decode($response->getContent(), true)
);
}
public function testCreateReturns400ForInvalidJson(): void
{
$this->service
->expects($this->never())
->method('create');
$request = new Request(
content: '{invalid json'
);
$response = $this->controller->create($request);
$this->assertSame(400, $response->getStatusCode());
}
public function testUpdateReturns200(): void
{
$data = [
'name' => 'Gaming Laptop',
'price' => 1299.99
];
$product = (new Product())
->setName('Gaming Laptop')
->setPrice(1299.99);
$this->service
->expects($this->once())
->method('update')
->with(1, $data)
->willReturn($product);
$request = new Request(
content: json_encode($data)
);
$response = $this->controller->update(1, $request);
$this->assertSame(200, $response->getStatusCode());
$result = json_decode($response->getContent(), true);
$this->assertSame(
'Gaming Laptop',
$result['name']
);
}
public function testUpdateReturns404(): void
{
$data = [
'name' => 'Laptop',
'price' => 100
];
$this->service
->expects($this->once())
->method('update')
->with(999, $data)
->willThrowException(
new ProductNotFoundException(999)
);
$request = new Request(
content: json_encode($data)
);
$response = $this->controller->update(999, $request);
$this->assertSame(404, $response->getStatusCode());
}
public function testDeleteReturns204(): void
{
$this->service
->expects($this->once())
->method('delete')
->with(1);
$response = $this->controller->delete(1);
$this->assertSame(204, $response->getStatusCode());
$this->assertSame('', $response->getContent());
}
public function testDeleteReturns404(): void
{
$this->service
->expects($this->once())
->method('delete')
->with(999)
->willThrowException(
new ProductNotFoundException(999)
);
$response = $this->controller->delete(999);
$this->assertSame(404, $response->getStatusCode());
}
}
These 10 controller tests do not use MySQL or Doctrine.
The key difference between the two test suites
The service test mocks persistence:
$this->repository = $this->createMock(
ProductRepository::class
);
$this->entityManager = $this->createMock(
EntityManagerInterface::class
);
The controller test mocks the entire service:
$this->service = $this->createMock(
ProductService::class
);
This allows the two layers to be tested independently.
12. Functional tests using WebTestCase
Unit tests verify each layer independently. Functional tests verify that the layers work together through Symfony’s HTTP kernel.
For this example, we’ll use a separate test database rather than mocks.
Configure .env.test.local:
DATABASE_URL="mysql://root:password@127.0.0.1:3306/product_api_test?serverVersion=8.0.36&charset=utf8mb4"
Create the test database and schema:
APP_ENV=test php bin/console doctrine:database:create
APP_ENV=test php bin/console doctrine:migrations:migrate --no-interaction
For this example, the functional test creates its own product and deletes it afterward. In a larger suite, use isolated fixtures and transaction rollback or database-reset tooling.
tests/Functional/ProductApiTest.php
<?php
namespace App\Tests\Functional;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProductApiTest extends WebTestCase
{
public function testGetProducts(): void
{
$client = static::createClient();
$client->request('GET', '/api/products');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame(
'content-type',
'application/json'
);
$data = json_decode(
$client->getResponse()->getContent(),
true
);
$this->assertIsArray($data);
}
public function testProductCrudLifecycle(): void
{
$client = static::createClient();
$client->request(
'POST',
'/api/products',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([
'name' => 'Test Product',
'price' => 25.50
])
);
$this->assertResponseStatusCodeSame(201);
$data = json_decode(
$client->getResponse()->getContent(),
true
);
$this->assertSame('Test Product', $data['name']);
$this->assertSame(25.50, $data['price']);
$this->assertIsInt($data['id']);
$id = $data['id'];
// READ
$client->request('GET', "/api/products/$id");
$this->assertResponseIsSuccessful();
// UPDATE
$client->request(
'PUT',
"/api/products/$id",
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([
'name' => 'Updated Product',
'price' => 35.00
])
);
$this->assertResponseIsSuccessful();
$updated = json_decode(
$client->getResponse()->getContent(),
true
);
$this->assertSame(
'Updated Product',
$updated['name']
);
// DELETE
$client->request('DELETE', "/api/products/$id");
$this->assertResponseStatusCodeSame(204);
// VERIFY DELETION
$client->request('GET', "/api/products/$id");
$this->assertResponseStatusCodeSame(404);
}
public function testCreateWithInvalidData(): void
{
$client = static::createClient();
$client->request(
'POST',
'/api/products',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([
'name' => '',
'price' => -10
])
);
$this->assertResponseStatusCodeSame(422);
}
public function testCreateWithInvalidJson(): void
{
$client = static::createClient();
$client->request(
'POST',
'/api/products',
server: ['CONTENT_TYPE' => 'application/json'],
content: '{invalid json'
);
$this->assertResponseStatusCodeSame(400);
}
}
These functional tests verify routing, dependency injection, JSON responses, database persistence, and HTTP status codes.
13. Run PHPUnit
Run all tests:
php bin/phpunit
Run only service tests:
php bin/phpunit tests/Unit/Service/ProductServiceTest.php
Run only controller tests:
php bin/phpunit tests/Unit/Controller/ProductControllerTest.php
Run functional tests:
php bin/phpunit tests/Functional/ProductApiTest.php
Run tests with code coverage (requires Xdebug or PCOV):
XDEBUG_MODE=coverage php bin/phpunit --coverage-html coverage/
Test suite summary:
| Suite | Test methods |
|---|---|
| Service unit tests | 11 |
| Controller unit tests | 10 |
| Functional tests | 4 |
| Total | 25 |
These are the tests provided in the example, not verified execution results. The code has not been run against an installed Symfony project in this conversation.
14. Test the REST API using curl
Start Symfony:
symfony server:start
Create a product:
curl -X POST http://localhost:8000/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Laptop","price":999.99}'
Expected JSON response:
{
"id": 1,
"name": "Laptop",
"price": 999.99
}
Get all products:
curl http://localhost:8000/api/products
Get one product:
curl http://localhost:8000/api/products/1
Update a product:
curl -X PUT http://localhost:8000/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Gaming Laptop","price":1299.99}'
Delete a product:
curl -X DELETE http://localhost:8000/api/products/1
15. Summary: responsibilities of each layer
| Component | Responsibility | Unit test dependencies |
|---|---|---|
ProductController |
HTTP requests, JSON parsing, status codes | Mock ProductService |
ProductService |
Business rules, validation, CRUD orchestration | Mock repository and entity manager |
ProductRepository |
Database queries | Integration tests with Doctrine |
Product |
Entity state and data | Direct unit tests if needed |
| Functional tests | Complete HTTP request lifecycle | Test database |
Improvements for production
The example is intentionally simple. For a production Symfony 8 API, I recommend extending it with Symfony Validator and request DTOs, pagination for product listings, authentication and authorization, consistent exception handling using an event subscriber, and transaction-safe persistence.
Also, the example’s PUT endpoint requires a complete representation of the product. If you want partial updates, add a separate PATCH endpoint.
The main architectural benefit: You can change your API controller, add a console command, or introduce another interface without rewriting the CRUD business logic. You can also test each layer independently using PHPUnit mocks, while functional tests verify that the complete application works together.