Doctrine Sample Application
Build a REST API that manages users through Doctrine ORM entities. You use Winter Boot for the application runtime and the winter-doctrine package for the EntityManager and transaction manager. Writes run inside programmatic transactions via EmTransactionManager.
Prerequisites
Section titled “Prerequisites”You need PHP 8.5 or later with the pdo_pgsql extension. You also need a PostgreSQL server. The sample config points at localhost:5432, database appdb, user appuser — replace them with your own server.
Start PostgreSQL before you run the app:
docker run -d -p 5432:5432 --name postgres \ -e POSTGRES_DB=appdb \ -e POSTGRES_USER=appuser \ -e POSTGRES_PASSWORD=apppass \ postgres:16Project structure
Section titled “Project structure”The sample uses this layout:
doctrine/├── bin/│ └── application.php # Application entry point├── config/│ └── application.yml # Datasource and Doctrine module config├── create-table.sql # Table setup script├── src/│ ├── DoctrineSampleApplication.php # Main application class│ ├── controller/│ │ └── UserController.php # User REST endpoints│ ├── model/│ │ └── User.php # Doctrine entity│ └── service/│ └── UserService.php # Transactional user operations└── composer.json # DependenciesInstall dependencies
Section titled “Install dependencies”Require the framework and the Doctrine package:
composer require suvera/winter-boot suvera/winter-doctrineSource files
Section titled “Source files”Switch between the source files. Each tab shows the exact file from the sample.
The single entry point. #[EnableTransactionManagement] activates the transaction infrastructure used by the service.
<?php
namespace dev\example;
use dev\winterframework\stereotype\WinterBootApplication;use dev\winterframework\stereotype\txn\EnableTransactionManagement;
#[WinterBootApplication( configDirectory: [__DIR__ . "/../config"], scanNamespaces: [ ['dev\\example', __DIR__ . ''] ])]#[EnableTransactionManagement]class DoctrineSampleApplication {
public static function main(): void { $winterApp = new \dev\winterframework\core\app\WinterWebSwooleApplication(); $winterApp->run(self::class); }}The entity. Attributes map the class to the doctrine_users table with an auto-generated id. The bombok\Data trait generates the getters and setters from the property declarations, so no accessor boilerplate is needed — the @method annotations keep IDEs and static analysis aware of them.
<?php
namespace dev\example\model;
use dev\winterframework\bombok\Data;use Doctrine\ORM\Mapping\Column;use Doctrine\ORM\Mapping\Entity;use Doctrine\ORM\Mapping\GeneratedValue;use Doctrine\ORM\Mapping\Id;use Doctrine\ORM\Mapping\Table;
/** * @method int getId() * @method setId(int $val): void * @method string getName() * @method setName(string $val): void * @method string getEmail() * @method setEmail(string $val): void */#[Entity]#[Table(name: "doctrine_users")]class User implements \JsonSerializable { use Data;
#[Id] #[GeneratedValue] #[Column(name: "id", type: "integer", nullable: false)] private int $id = 0;
#[Column(name: "name", type: "string", nullable: false)] private string $name = '';
#[Column(name: "email", type: "string", nullable: false)] private string $email = '';
public function jsonSerialize(): array { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, ]; }}Setters validate argument types against the property declarations. See Utilities for the full Data trait reference.
The service. Each write opens a transaction on EmTransactionManager, commits on success, and rolls back on any failure. Reads use the EntityManager directly.
<?php
namespace dev\example\service;
use dev\example\model\User;use dev\winterframework\doctrine\orm\EmTransactionManager;use dev\winterframework\stereotype\Autowired;use dev\winterframework\stereotype\Service;use dev\winterframework\txn\support\DefaultTransactionDefinition;use Doctrine\ORM\EntityManager;
#[Service]class UserService {
#[Autowired] private EmTransactionManager $txnManager;
private function getEm(): EntityManager { return $this->txnManager->getEntityManager(); }
public function createUser(User $user): User { $status = $this->txnManager->getTransaction(new DefaultTransactionDefinition()); try { $this->getEm()->persist($user); $this->getEm()->flush(); $this->txnManager->commit($status); return $user; } catch (\Throwable $e) { $this->txnManager->rollback($status); throw $e; } }
public function deleteUser(int $id): bool { $status = $this->txnManager->getTransaction(new DefaultTransactionDefinition()); try { $user = $this->findById($id); if ($user) { $this->getEm()->remove($user); $this->getEm()->flush(); $this->txnManager->commit($status); return true; } $this->txnManager->commit($status); return false; } catch (\Throwable $e) { $this->txnManager->rollback($status); throw $e; } }
public function findById(int $id): ?User { return $this->getEm()->find(User::class, $id); }
public function findAll(): array { return $this->getEm()->getRepository(User::class)->findAll(); }
public function findByEmail(string $email): ?User { return $this->getEm()->getRepository(User::class)->findOneBy(['email' => $email]); }}The controller. It rejects duplicate emails on create and returns 404-style payloads for missing users.
<?php
namespace dev\example\controller;
use dev\example\model\User;use dev\example\service\UserService;use dev\winterframework\stereotype\Autowired;use dev\winterframework\stereotype\RestController;use dev\winterframework\stereotype\web\DeleteMapping;use dev\winterframework\stereotype\web\GetMapping;use dev\winterframework\stereotype\web\PostMapping;use dev\winterframework\stereotype\web\PathVariable;use dev\winterframework\stereotype\web\RequestBody;
#[RestController]class UserController {
#[Autowired] protected UserService $userService;
#[GetMapping(path: "/users")] public function getAllUsers(): array { return [ 'success' => true, 'data' => $this->userService->findAll() ]; }
#[GetMapping(path: "/users/{id}")] public function getUserById(#[PathVariable] int $id): array { $user = $this->userService->findById($id);
if ($user) { return [ 'success' => true, 'data' => $user ]; } return [ 'success' => false, 'message' => 'User not found' ]; }
#[PostMapping(path: "/users")] public function createUser(#[RequestBody] User $user): array { $existing = $this->userService->findByEmail($user->getEmail()); if ($existing) { return [ 'success' => false, 'message' => 'User with email ' . $user->getEmail() . ' already exists' ]; }
$created = $this->userService->createUser($user); return [ 'success' => true, 'data' => $created, 'message' => 'User created successfully' ]; }
#[DeleteMapping(path: "/users/{id}")] public function deleteUser(#[PathVariable] int $id): array { if ($this->userService->deleteUser($id)) { return [ 'success' => true, 'message' => 'User deleted successfully' ]; } return [ 'success' => false, 'message' => 'User not found' ]; }}The launch script. It loads the Composer autoloader and starts the application.
<?php
use dev\example\DoctrineSampleApplication;
require_once(dirname(__DIR__) . '/vendor/autoload.php');
DoctrineSampleApplication::main();The sample declares framework dependencies with PSR-4 autoloading for its own namespace.
{ "name": "suvera/winter-boot-doctrine-sample", "require": { "ext-pcntl": "*", "ext-swoole": "*", "ext-pdo_pgsql": "*", "suvera/winter-boot": "@dev", "suvera/winter-doctrine": "@dev" }, "autoload": { "psr-4": { "dev\\example\\": "src/" } }}The runnable sample in winter-boot-samples adds local path repositories for winter-boot and winter-doctrine so they resolve from sibling checkouts. You do not need those entries when you install released packages from Packagist.
Configuration
Section titled “Configuration”Switch between the two config files. application.yml registers the module and the datasource, and create-table.sql creates the table.
The full sample file registers DoctrineModule, marks defaultdb as primary, and points the ORM at the model directory. It keeps only the server, app identity, module, and datasource keys:
server: port: 8080 address: 0.0.0.0 context-path: /winter: application: name: Doctrine Sample Application id: doctrine-sample-app version: 1.0.0modules: - module: dev\winterframework\doctrine\DoctrineModule enabled: truedatasource: - name: defaultdb isPrimary: true url: "pgsql:host=localhost;port=5432;dbname=appdb" username: appuser password: apppass validationQuery: SELECT 'Database Connected' driverClass: dev\winterframework\pdbc\pdo\PdoDataSource connection: persistent: false errorMode: ERRMODE_EXCEPTION autoCommit: false defaultrowprefetch: 100 idleTimeout: 180 charset: utf8 schema: public doctrine: entityPaths: - /path/to/src/modelReplace the connection details with your own server, and entityPaths with the absolute path of your src/model directory. See Configuration for every application.yml key.
Creates the table before the first run:
CREATE TABLE IF NOT EXISTS doctrine_users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE);Run the app
Section titled “Run the app”Create the table, start the app, then create and read a user.
1. Create the table:
psql -h localhost -p 5432 -U appuser -d appdb -f create-table.sql2. Start the application:
composer installphp bin/application.php3. Create a user:
curl -X POST "http://localhost:8080/users" \ -H "Content-Type: application/json" \ -d '{"name":"Ada Lovelace","email":"ada@example.com"}'4. List users:
curl "http://localhost:8080/users"5. Delete the user:
curl -X DELETE "http://localhost:8080/users/1"Next steps
Section titled “Next steps”- Read the Doctrine module for DBAL access, multi-tenant datasources, and
#[Transactional]managers. - Browse all Libraries when you need Redis, Kafka, S3, or OpenSearch in the same app.