Framework Development Skills
Skill 1: Create PHP Controller
When creating a new Controller:
-
Location: Place in
app/Http/Controllers/orapp/Http/Controllers/{Domain}/following domain structure -
Structure:
- Use constructor injection for dependencies (ViewRenderer, Response, Services, Repositories)
- Methods receive
Request $requestas first parameter - Return
Responseinstance - Use fluent Response methods (json, html, redirect, etc.)
-
Example Pattern:
php1<?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.phpif 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:
php1<?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
- Inject
-
Example Pattern:
php1<?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
- Initialize Propel in constructor via
-
Example Pattern:
php1<?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:
php1use 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:
php1// 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, nottoArray()(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)
- Extend
-
Example Pattern:
php1<?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:
php1$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
Responsevia constructor handle()method receivesRequestandcallable $next- Return
Responsefrom$next($request)or error response
- Implement
-
Example Pattern:
php1<?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()anddown()methods - Use raw SQL for CREATE/ALTER/DROP (DDL operations)
- Add proper indexes and foreign keys
- Use transactions for multi-step operations
- Implement
-
Example Pattern:
php1<?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
- Implement
-
Example Pattern:
php1<?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.xmlat project root -
Structure:
- Use Propel XML schema format
- Use
LONGVARCHARinstead ofTEXTfor compatibility - Define foreign keys and relationships
- Use ENUM types for constrained values
-
After modifying schema.xml:
bash1# 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
- Generated files in
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()andtearDown()for test isolation - Use database transactions or fresh database for each test
- Follow AAA pattern (Arrange, Act, Assert)
- Extend
-
Example Pattern:
php1<?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/phpunitorcomposer 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