Framework Development Skills
Skill 1: Create PHP Controller
When creating a new Controller:
-
Location: Place in app/Http/Controllers/ or app/Http/Controllers/{Domain}/ following domain structure
-
Structure:
- Use constructor injection for dependencies (ViewRenderer, Response, Services, Repositories)
- Methods receive
Request $request as first parameter
- Return
Response instance
- Use fluent Response methods (json, html, redirect, etc.)
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Http\Controllers\{Domain};
6
7use App\Http\Request;
8use App\Http\Response;
9use App\Services\View\ViewRenderer;
10use App\Services\{Domain}\{Name}Service;
11
12class {Name}Controller
13{
14 public function __construct(
15 private ViewRenderer $viewRenderer,
16 private Response $response,
17 private {Name}Service $service
18 ) {
19 }
20
21 public function index(Request $request): Response
22 {
23 $data = $this->service->getAll();
24 return $this->response->json(['data' => $data]);
25 }
26}
- Register in Container: Add binding in
app/Providers/AppServiceProvider.php if needed
- Add Route: Define route in
routes/web.php
- Create Tests: Add corresponding test in
tests/framework/Http/Controllers/
Skill 2: Create Domain Service
When creating a new Service:
-
Location: Place in app/Services/{Domain}/ following domain structure
-
Structure:
- Single Responsibility Principle (one reason to change)
- Constructor injection for dependencies (Repositories, other Services, Logger)
- Use typed exceptions for error handling
- Methods should be focused and testable
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Services\{Domain};
6
7use App\Repositories\{Domain}\{Name}Repository;
8use App\Services\Logger\Logger;
9use App\Exceptions\{Domain}\{Name}Exception;
10
11class {Name}Service
12{
13 public function __construct(
14 private {Name}Repository $repository,
15 private Logger $logger
16 ) {
17 }
18
19 public function doSomething(string $param): mixed
20 {
21 // Implementation with validation, logging, error handling
22 return $result;
23 }
24}
- Register in Container: Add singleton binding in
app/Providers/AppServiceProvider.php
- Create Tests: Add corresponding test in
tests/framework/Services/{Domain}/
Skill 3: Create Repository with Propel ORM
CRITICAL: Framework uses 100% Propel ORM. Never use QueryBuilder or raw SQL for data operations.
When creating a new Repository:
-
Location: Place in app/Repositories/{Domain}/ following domain structure
-
Structure:
- Inject
PropelConnector (or domain-specific connector) via constructor
- All data operations go through Propel models
- Use
executeInTransaction() for multi-step operations
- Convert Propel models to arrays using
toArray() helper method
- Never use QueryBuilder or raw SQL for data access
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Repositories\{Domain};
6
7use App\Repositories\Connectors\{Name}Connector;
8use App\Models\{Name};
9
10class {Name}Repository
11{
12 public function __construct(
13 private {Name}Connector $connector
14 ) {
15 }
16
17 public function findById(int $id): ?array
18 {
19 $model = $this->connector->find{Name}ById($id);
20 return $model ? $this->toArray($model) : null;
21 }
22
23 public function findAll(array $conditions = [], array $orderBy = [], ?int $limit = null, ?int $offset = null): array
24 {
25 $models = $this->connector->findAll{Name}s($conditions, $orderBy, $limit, $offset);
26 return array_map([$this, 'toArray'], $models);
27 }
28
29 public function create(array $data): int
30 {
31 return $this->connector->executeInTransaction(function () use ($data) {
32 $model = $this->connector->create{Name}($data);
33 return $model->getId();
34 });
35 }
36
37 public function update(int $id, array $data): int
38 {
39 return $this->connector->executeInTransaction(function () use ($id, $data) {
40 $model = $this->get{Name}OrFail($id);
41 $this->connector->update{Name}($model, $data);
42 return 1;
43 });
44 }
45
46 public function delete(int $id): int
47 {
48 return $this->connector->executeInTransaction(function () use ($id) {
49 $model = $this->get{Name}OrFail($id);
50 $this->connector->delete{Name}($model);
51 return 1;
52 });
53 }
54
55 private function get{Name}OrFail(int $id): {Name}
56 {
57 $model = $this->connector->find{Name}ById($id);
58 if ($model === null) {
59 throw new \RuntimeException("{Name} with ID {$id} not found");
60 }
61 return $model;
62 }
63
64 private function toArray({Name} $model): array
65 {
66 return [
67 'id' => $model->getId(),
68 // Map all properties
69 'created_at' => $model->getCreatedAt()?->format('Y-m-d H:i:s'),
70 'updated_at' => $model->getUpdatedAt()?->format('Y-m-d H:i:s'),
71 ];
72 }
73}
- Create Tests: Add corresponding test in
tests/framework/Repositories/{Domain}/
Skill 4: Create Propel Connector
When creating a new Propel Connector:
-
Location: Place in app/Repositories/Connectors/
-
Structure:
- Initialize Propel in constructor via
PropelInitializer::initialize()
- Use Propel Query classes (e.g.,
UserQuery, ProductQuery)
- Use Propel Model classes (e.g.,
User, Product)
- Wrap queries in
executeQuery() for error handling
- Use
executeInTransaction() for write operations
- Never use raw SQL or QueryBuilder
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Repositories\Connectors;
6
7use App\Models\{Name};
8use App\Models\{Name}Query;
9use App\Repositories\Connectors\PropelInitializer;
10use Propel\Runtime\Propel;
11use Propel\Runtime\Exception\PropelException;
12
13class {Name}Connector
14{
15 public function __construct()
16 {
17 PropelInitializer::initialize();
18 }
19
20 public function find{Name}ById(int $id): ?{Name}
21 {
22 return $this->executeQuery(fn() => {Name}Query::create()->findPk($id));
23 }
24
25 public function find{Name}By{Field}(string $value): ?{Name}
26 {
27 return $this->executeQuery(fn() => {Name}Query::create()->findOneBy{Field}($value));
28 }
29
30 public function findAll{Name}s(array $conditions = [], array $orderBy = [], ?int $limit = null, ?int $offset = null): array
31 {
32 return $this->executeQuery(
33 fn() => $this->buildQuery($conditions, $orderBy, $limit, $offset)->find()->getData(),
34 []
35 );
36 }
37
38 public function create{Name}(array $data): {Name}
39 {
40 $model = new {Name}();
41 $model->set{Field}($data['field']);
42 // Set all fields
43 $model->save();
44 return $model;
45 }
46
47 public function update{Name}({Name} $model, array $data): {Name}
48 {
49 if (isset($data['field'])) {
50 $model->set{Field}($data['field']);
51 }
52 $model->save();
53 return $model;
54 }
55
56 public function delete{Name}({Name} $model): void
57 {
58 $model->delete();
59 }
60
61 public function executeInTransaction(callable $callback): mixed
62 {
63 $connection = Propel::getConnection();
64 $connection->beginTransaction();
65 try {
66 $result = $callback();
67 $connection->commit();
68 return $result;
69 } catch (\Exception $e) {
70 $connection->rollBack();
71 throw $e;
72 }
73 }
74
75 private function buildQuery(array $conditions, array $orderBy, ?int $limit, ?int $offset): {Name}Query
76 {
77 $query = {Name}Query::create();
78
79 foreach ($conditions as $field => $value) {
80 $method = 'filterBy' . ucfirst($field);
81 if (method_exists($query, $method)) {
82 $query->$method($value);
83 }
84 }
85
86 foreach ($orderBy as $field => $direction) {
87 $method = 'orderBy' . ucfirst($field);
88 if (method_exists($query, $method)) {
89 $query->$method($direction);
90 }
91 }
92
93 if ($limit !== null) {
94 $query->limit($limit);
95 }
96
97 if ($offset !== null) {
98 $query->offset($offset);
99 }
100
101 return $query;
102 }
103
104 private function executeQuery(callable $callback, mixed $default = null): mixed
105 {
106 try {
107 return $callback();
108 } catch (PropelException $e) {
109 error_log("Propel error: " . $e->getMessage());
110 return $default;
111 }
112 }
113}
- Create Tests: Add corresponding test in
tests/framework/Repositories/Connectors/
Skill 5: Use Propel ORM Models
When working with Propel models:
- Always use Propel Query classes for reads:
php
1use App\Models\User;
2use App\Models\UserQuery;
3
4// Find by primary key
5$user = UserQuery::create()->findPk(1);
6
7// Find by unique field
8$user = UserQuery::create()->findOneByEmail('user@example.com');
9
10// Filter and order
11$admins = UserQuery::create()
12 ->filterByRole('admin')
13 ->orderByCreatedAt('DESC')
14 ->find();
15
16// Count
17$count = UserQuery::create()->filterByRole('user')->count();
- Always use Propel Model classes for writes:
php
1// Create
2$user = new User();
3$user->setEmail('new@example.com');
4$user->setPassword($hashedPassword);
5$user->setName('New User');
6$user->save();
7
8// Update
9$user = UserQuery::create()->findPk(1);
10$user->setName('Updated Name');
11$user->save();
12
13// Delete
14$user = UserQuery::create()->findPk(1);
15$user->delete();
- Never use raw SQL or QueryBuilder for data operations
- Use
getData() on collections, not toArray() (returns Collection of models)
Skill 6: Create Request Validation
When creating a new Request validation:
-
Location: Place in app/Http/Requests/{Domain}/ following domain structure
-
Structure:
- Extend
BaseRequest
- Implement
rules() method returning validation rules array
- Use
validated() method to get validated data
- Validation errors automatically return 422 Response
- Use whitelist approach (only allow specified fields)
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Http\Requests\{Domain};
6
7use App\Http\Requests\BaseRequest;
8
9class {Name}Request extends BaseRequest
10{
11 protected function rules(): array
12 {
13 return [
14 'field1' => ['required', 'string', 'min:3', 'max:255'],
15 'field2' => ['required', 'email'],
16 'field3' => ['numeric', 'min:0'],
17 'field4' => ['required', 'in:value1,value2'],
18 ];
19 }
20}
- Usage in Controller:
php
1$request = new {Name}Request($request, $validator, $response);
2$data = $request->validated(); // Returns array or sends 422 Response
- Create Tests: Add corresponding test in
tests/framework/Http/Requests/{Domain}/
Skill 7: Create Middleware
When creating a new Middleware:
-
Location: Place in app/Http/Middlewares/
-
Structure:
- Implement
MiddlewareInterface
- Inject
Response via constructor
handle() method receives Request and callable $next
- Return
Response from $next($request) or error response
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Http\Middlewares;
6
7use App\Http\Request;
8use App\Http\Response;
9use App\Http\Middlewares\MiddlewareInterface;
10
11class {Name}Middleware implements MiddlewareInterface
12{
13 public function __construct(
14 private Response $response
15 ) {
16 }
17
18 public function handle(Request $request, callable $next): Response
19 {
20 // Pre-processing logic
21
22 if (!$this->shouldProceed($request)) {
23 return $this->response->forbidden('Access denied');
24 }
25
26 $response = $next($request);
27
28 // Post-processing logic (optional)
29
30 return $response;
31 }
32
33 private function shouldProceed(Request $request): bool
34 {
35 // Validation logic
36 return true;
37 }
38}
- Register in Router: Use in route definitions or route groups in
routes/web.php
- Create Tests: Add corresponding test in
tests/framework/Http/Middlewares/
Skill 8: Create Database Migration
When creating a new migration:
-
Location: Place in database/migrations/
-
Naming: {timestamp}_{description}.php (e.g., 20240114120000_create_users_table.php)
-
Structure:
- Implement
MigrationInterface
- Use direct PDO via
Connection::getInstance() for DDL operations
- Include both
up() and down() methods
- Use raw SQL for CREATE/ALTER/DROP (DDL operations)
- Add proper indexes and foreign keys
- Use transactions for multi-step operations
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace Database\Migrations;
6
7use App\Database\Migrations\MigrationInterface;
8use App\Repositories\Database\Connection;
9use PDO;
10
11class {Name}Migration implements MigrationInterface
12{
13 private PDO $pdo;
14
15 public function __construct()
16 {
17 $this->pdo = Connection::getInstance();
18 }
19
20 public function up(): void
21 {
22 $this->pdo->exec("
23 CREATE TABLE {table_name} (
24 id INT AUTO_INCREMENT PRIMARY KEY,
25 column1 VARCHAR(255) NOT NULL,
26 column2 INT,
27 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
28 INDEX idx_column1 (column1)
29 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
30 ");
31 }
32
33 public function down(): void
34 {
35 $this->pdo->exec("DROP TABLE IF EXISTS {table_name}");
36 }
37}
- Security: Migrations use DDL (CREATE/ALTER/DROP) - no user input involved
- Create Tests: Add corresponding test in
tests/framework/Database/Migrations/
Skill 9: Create Database Seeder
When creating a new seeder:
-
Location: Place in database/seeders/
-
Naming: {timestamp}_{description}.php (e.g., 20240101000000_DefaultUsersSeeder.php)
-
Structure:
- Implement
SeederInterface
- Use
PropelConnector (or domain-specific connector) for all data operations
- Check for existing records before creating (update if exists)
- Use Propel models, never raw SQL
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace Database\Seeders;
6
7use App\Database\Seeders\SeederInterface;
8use App\Repositories\Connectors\PropelConnector;
9use App\Services\Security\HashService;
10
11class {Name}Seeder implements SeederInterface
12{
13 private HashService $hashService;
14
15 public function __construct()
16 {
17 $this->hashService = new HashService();
18 }
19
20 public function run(PropelConnector $connector): void
21 {
22 $items = [
23 [
24 'field1' => 'value1',
25 'field2' => 'value2',
26 ],
27 ];
28
29 foreach ($items as $itemData) {
30 $existing = $connector->find{Name}By{Field}($itemData['field']);
31
32 if ($existing === null) {
33 $connector->create{Name}($itemData);
34 } else {
35 // Update existing record
36 $connector->update{Name}($existing, $itemData);
37 }
38 }
39 }
40}
- Create Tests: Add corresponding test in
tests/framework/Database/Seeders/
Skill 10: Modify Propel Schema
When modifying the database schema:
-
Location: Edit schema.xml at project root
-
Structure:
- Use Propel XML schema format
- Use
LONGVARCHAR instead of TEXT for compatibility
- Define foreign keys and relationships
- Use ENUM types for constrained values
-
After modifying schema.xml:
bash
1# Generate Propel configuration
2vendor/bin/propel config:convert
3
4# Generate models
5vendor/bin/propel model:build --schema-dir=. --output-dir=app
- Important:
- Generated files in
app/Models/Base/ are auto-generated - manual fixes will be overwritten
- If ENUM handling needs fixes, re-apply after each
model:build
- Create migration for schema changes if needed
Skill 11: Create PHPUnit Test
When creating a new test:
-
Location: Place in tests/framework/{Category}/ matching app structure
-
Structure:
- Extend
Tests\Support\TestCase
- Use
setUp() and tearDown() for test isolation
- Use database transactions or fresh database for each test
- Follow AAA pattern (Arrange, Act, Assert)
-
Example Pattern:
php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Framework\{Category};
6
7use Tests\Support\TestCase;
8use App\{Category}\{Name};
9
10class {Name}Test extends TestCase
11{
12 protected function setUp(): void
13 {
14 parent::setUp();
15 // Setup test data
16 }
17
18 public function testSomething(): void
19 {
20 // Arrange
21 $input = 'value';
22
23 // Act
24 $result = $this->subject->method($input);
25
26 // Assert
27 $this->assertEquals('expected', $result);
28 }
29}
- Run tests:
vendor/bin/phpunit or composer test
Key Principles
-
100% Propel ORM: Never use QueryBuilder or raw SQL for data operations. Only use direct PDO for DDL in migrations.
-
Architecture: Controller → Service → Repository → Connector (Propel) → Database
-
SOLID Principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion
-
Security:
- CSRF protection on all forms
- Input validation with whitelist approach
- Password hashing (bcrypt)
- SQL injection protection (Propel ORM)
- XSS protection (input sanitization)
-
Code Quality: DRY, KISS, YAGNI, "Sur la coche" (all quality rules applied)
-
Testing: Comprehensive test coverage, TDD when appropriate