28 KiB
Common Development Workflows
Standard workflows for common development tasks in the Custom PHP Framework.
Adding a New Feature
Step 1: Planning & Design
Define the Feature:
# Document feature requirements
# - What problem does it solve?
# - What are the acceptance criteria?
# - What are the security implications?
# - What is the performance impact?
Design Decision Checklist:
- Does this fit framework architecture?
- Are there existing patterns to follow?
- What Value Objects are needed?
- What events should be dispatched?
- What tests are required?
Step 2: Create Domain Model
Create Value Objects:
// src/Domain/Orders/ValueObjects/OrderId.php
namespace App\Domain\Orders\ValueObjects;
final readonly class OrderId
{
public function __construct(public string $value)
{
if (empty($value)) {
throw new \InvalidArgumentException('OrderId cannot be empty');
}
}
public static function generate(): self
{
return new self(\Symfony\Component\Uid\Ulid::generate());
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
Create Domain Entity:
// src/Domain/Orders/Entities/Order.php
namespace App\Domain\Orders\Entities;
use App\Domain\Orders\ValueObjects\{OrderId, OrderStatus};
use App\Framework\Core\ValueObjects\Timestamp;
final readonly class Order
{
public function __construct(
public OrderId $id,
public UserId $userId,
public OrderStatus $status,
public Money $total,
public Timestamp $createdAt
) {}
public static function create(UserId $userId, Money $total): self
{
return new self(
id: OrderId::generate(),
userId: $userId,
status: OrderStatus::PENDING,
total: $total,
createdAt: Timestamp::now()
);
}
public function confirm(): self
{
if (!$this->status->equals(OrderStatus::PENDING)) {
throw new \DomainException('Only pending orders can be confirmed');
}
return new self(
id: $this->id,
userId: $this->userId,
status: OrderStatus::CONFIRMED,
total: $this->total,
createdAt: $this->createdAt
);
}
}
Regenerate Autoloader:
composer reload
# Or in Docker
make reload
Step 3: Create Service Layer
Create Service:
// src/Domain/Orders/Services/OrderService.php
namespace App\Domain\Orders\Services;
use App\Domain\Orders\{Entities\Order, Repositories\OrderRepository};
use App\Framework\Events\EventDispatcher;
use App\Domain\Orders\Events\OrderCreatedEvent;
final readonly class OrderService
{
public function __construct(
private readonly OrderRepository $repository,
private readonly EventDispatcher $events
) {}
public function createOrder(UserId $userId, Money $total): Order
{
// Create order
$order = Order::create($userId, $total);
// Persist
$this->repository->save($order);
// Dispatch event
$this->events->dispatch(new OrderCreatedEvent($order));
return $order;
}
public function confirmOrder(OrderId $orderId): Order
{
// Find order
$order = $this->repository->findById($orderId);
if ($order === null) {
throw new OrderNotFoundException($orderId);
}
// Confirm
$confirmedOrder = $order->confirm();
// Update
$this->repository->save($confirmedOrder);
// Dispatch event
$this->events->dispatch(new OrderConfirmedEvent($confirmedOrder));
return $confirmedOrder;
}
}
Register Service in Container:
// src/Domain/Orders/OrdersInitializer.php
namespace App\Domain\Orders;
use App\Framework\DI\Container;
use App\Framework\Attributes\Initializer;
final readonly class OrdersInitializer
{
#[Initializer]
public function initialize(Container $container): void
{
// Register repository
$container->singleton(
OrderRepository::class,
new DatabaseOrderRepository(
$container->get(Connection::class)
)
);
// Register service
$container->singleton(
OrderService::class,
fn() => new OrderService(
$container->get(OrderRepository::class),
$container->get(EventDispatcher::class)
)
);
}
}
Regenerate Autoloader:
composer reload
Step 4: Create API Endpoint
Create Controller:
// src/Application/Api/OrderController.php
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\{Method, JsonResult, Request};
use App\Domain\Orders\Services\OrderService;
final readonly class OrderController
{
public function __construct(
private readonly OrderService $orderService
) {}
#[Route(path: '/api/orders', method: Method::POST)]
#[Auth(strategy: 'session')]
public function createOrder(CreateOrderRequest $request): JsonResult
{
$order = $this->orderService->createOrder(
userId: $request->userId,
total: $request->total
);
return new JsonResult([
'order_id' => $order->id->value,
'status' => $order->status->value,
'total' => $order->total->toArray()
], status: Status::CREATED);
}
#[Route(path: '/api/orders/{orderId}/confirm', method: Method::POST)]
#[Auth(strategy: 'session')]
public function confirmOrder(string $orderId): JsonResult
{
$order = $this->orderService->confirmOrder(
new OrderId($orderId)
);
return new JsonResult([
'order_id' => $order->id->value,
'status' => $order->status->value
]);
}
}
Create Request Object:
// src/Application/Api/Requests/CreateOrderRequest.php
namespace App\Application\Api\Requests;
use App\Framework\Http\{Request as HttpRequest, ControllerRequest};
final readonly class CreateOrderRequest implements ControllerRequest
{
public function __construct(
public UserId $userId,
public Money $total
) {}
public static function fromHttpRequest(HttpRequest $request): self
{
$data = $request->parsedBody->toArray();
return new self(
userId: new UserId($data['user_id'] ?? ''),
total: Money::fromCents((int) ($data['total_cents'] ?? 0))
);
}
}
Verify Route Registration:
docker exec php php console.php routes:list | grep orders
Step 5: Create Database Migration
# Generate migration
docker exec php php console.php make:migration CreateOrdersTable Domain/Orders
# Edit migration
# src/Domain/Orders/Database/Migrations/2025_01_28_120000_CreateOrdersTable.php
Migration Implementation:
namespace App\Domain\Orders\Database\Migrations;
use App\Framework\Database\Migration\{Migration, SafelyReversible};
use App\Framework\Database\Schema\{Blueprint, Schema};
final readonly class CreateOrdersTable implements Migration, SafelyReversible
{
public function up(Schema $schema): void
{
$schema->create('orders', function (Blueprint $table) {
$table->string('ulid', 26)->primary();
$table->string('user_id', 26);
$table->string('status', 20);
$table->integer('total_cents');
$table->string('currency', 3)->default('EUR');
$table->timestamps();
// Indexes
$table->index('user_id');
$table->index('status');
$table->index('created_at');
// Foreign key
$table->foreign('user_id')
->references('ulid')
->on('users')
->onDelete(ForeignKeyAction::CASCADE);
});
}
public function down(Schema $schema): void
{
$schema->dropIfExists('orders');
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromString('2025_01_28_120000');
}
public function getDescription(): string
{
return 'Create orders table';
}
}
Run Migration:
docker exec php php console.php db:migrate
Step 6: Write Tests
Unit Test - Domain Logic:
// tests/Unit/Domain/Orders/Entities/OrderTest.php
use App\Domain\Orders\Entities\Order;
use App\Domain\Orders\ValueObjects\{OrderId, OrderStatus};
describe('Order Entity', function () {
it('creates order with pending status', function () {
$order = Order::create(
userId: new UserId('user-123'),
total: Money::fromEuros(99.99)
);
expect($order->status)->toEqual(OrderStatus::PENDING);
expect($order->total->toEuros())->toBe(99.99);
});
it('confirms pending order', function () {
$order = Order::create(
userId: new UserId('user-123'),
total: Money::fromEuros(99.99)
);
$confirmed = $order->confirm();
expect($confirmed->status)->toEqual(OrderStatus::CONFIRMED);
});
it('throws exception when confirming non-pending order', function () {
$order = Order::create(
userId: new UserId('user-123'),
total: Money::fromEuros(99.99)
)->confirm();
$order->confirm(); // Should throw
})->throws(\DomainException::class);
});
Integration Test - API Endpoint:
// tests/Feature/Api/OrderControllerTest.php
use App\Application\Api\OrderController;
describe('Order API', function () {
it('creates order via API', function () {
$response = $this->post('/api/orders', [
'user_id' => 'user-123',
'total_cents' => 9999
]);
expect($response->status)->toBe(Status::CREATED);
expect($response->json('order_id'))->not->toBeNull();
expect($response->json('status'))->toBe('pending');
});
it('confirms order via API', function () {
// Create order first
$createResponse = $this->post('/api/orders', [
'user_id' => 'user-123',
'total_cents' => 9999
]);
$orderId = $createResponse->json('order_id');
// Confirm order
$confirmResponse = $this->post("/api/orders/{$orderId}/confirm");
expect($confirmResponse->status)->toBe(Status::OK);
expect($confirmResponse->json('status'))->toBe('confirmed');
});
});
Run Tests:
./vendor/bin/pest
Step 7: Documentation
Update API Documentation:
## Create Order
**Endpoint**: `POST /api/orders`
**Auth**: Session required
**Request Body**:
```json
{
"user_id": "01HQXYZ...",
"total_cents": 9999
}
Response (201 Created):
{
"order_id": "01HQABC...",
"status": "pending",
"total": {
"cents": 9999,
"currency": "EUR"
}
}
---
### Step 8: Code Review Checklist
- [ ] Value Objects for all domain concepts
- [ ] Immutable entities (readonly classes)
- [ ] Events dispatched for important actions
- [ ] Exception handling implemented
- [ ] Tests cover happy path and edge cases
- [ ] Migration includes indexes and foreign keys
- [ ] API returns appropriate status codes
- [ ] Security: Authentication/Authorization applied
- [ ] Performance: N+1 queries avoided
- [ ] Documentation updated
---
## Implementing an API Endpoint
### Quick API Endpoint Guide
**1. Create Controller Method**:
```php
#[Route(path: '/api/resource', method: Method::GET)]
public function getResource(string $id): JsonResult
{
$resource = $this->service->find($id);
return new JsonResult($resource->toArray());
}
2. Add Authentication:
#[Route(path: '/api/resource', method: Method::POST)]
#[Auth(strategy: 'session', roles: ['admin'])]
public function createResource(CreateRequest $request): JsonResult
{
// Only authenticated admins can access
}
3. Create Request Object:
final readonly class CreateRequest implements ControllerRequest
{
public function __construct(
public string $name,
public Email $email
) {}
public static function fromHttpRequest(HttpRequest $request): self
{
$data = $request->parsedBody->toArray();
return new self(
name: trim($data['name'] ?? ''),
email: new Email($data['email'] ?? '')
);
}
}
4. Handle Errors:
try {
$resource = $this->service->create($request);
return new JsonResult(
$resource->toArray(),
status: Status::CREATED
);
} catch (ValidationException $e) {
return new JsonResult(
['errors' => $e->getErrors()],
status: Status::UNPROCESSABLE_ENTITY
);
} catch (\Exception $e) {
Logger::error('[API] Unexpected error', ['exception' => $e]);
return new JsonResult(
['error' => 'Internal server error'],
status: Status::INTERNAL_SERVER_ERROR
);
}
5. Test Endpoint:
# Manual test
curl -X POST https://localhost/api/resource \
-H "Content-Type: application/json" \
-H "User-Agent: Mozilla/5.0" \
-d '{"name":"Test","email":"test@example.com"}'
# Automated test
./vendor/bin/pest --filter CreateResourceTest
REST API Best Practices
HTTP Methods:
GET- Retrieve resources (idempotent)POST- Create new resourcesPUT- Update entire resource (idempotent)PATCH- Partial updateDELETE- Remove resource (idempotent)
Status Codes:
200 OK- Successful GET/PUT/PATCH201 Created- Successful POST with resource created204 No Content- Successful DELETE400 Bad Request- Invalid request format401 Unauthorized- Authentication required403 Forbidden- Insufficient permissions404 Not Found- Resource doesn't exist422 Unprocessable Entity- Validation errors429 Too Many Requests- Rate limit exceeded500 Internal Server Error- Server-side error
Response Format:
// Success response
return new JsonResult([
'data' => $resource->toArray(),
'meta' => [
'timestamp' => time(),
'version' => '2.0'
]
]);
// Error response
return new JsonResult([
'error' => [
'code' => 'VALIDATION_ERROR',
'message' => 'Validation failed',
'details' => [
'email' => ['Email format is invalid']
]
]
], status: Status::UNPROCESSABLE_ENTITY);
Bug Fix Workflow
Step 1: Reproduce the Bug
Create Reproduction Test:
// tests/Unit/Bug123Test.php
describe('Bug #123: Orders not updating status', function () {
it('reproduces the bug', function () {
$order = Order::create(/* ... */);
$order->confirm();
// This should update status but doesn't
expect($order->status)->toBe(OrderStatus::CONFIRMED);
})->skip('Bug reproduction - currently failing');
});
Run Test to Confirm Failure:
./vendor/bin/pest --filter Bug123Test
Step 2: Debug and Identify Root Cause
Enable Debug Logging:
// Add logging to suspected code
Logger::debug('[OrderService] Confirming order', [
'order_id' => $order->id->value,
'current_status' => $order->status->value
]);
$confirmedOrder = $order->confirm();
Logger::debug('[OrderService] Order confirmed', [
'order_id' => $confirmedOrder->id->value,
'new_status' => $confirmedOrder->status->value
]);
Check Database Queries:
# Enable query logging
docker exec php php -r "
\$logger = new App\Framework\Database\QueryLogger();
// Run problematic code
"
Use MCP Server for Analysis:
# Analyze routes
docker exec php php console.php mcp:analyze routes
# Analyze container bindings
docker exec php php console.php mcp:analyze container
Step 3: Fix the Bug
Identify and Fix:
// ❌ Bug: Not saving updated order
public function confirmOrder(OrderId $orderId): Order
{
$order = $this->repository->findById($orderId);
$confirmedOrder = $order->confirm();
// Missing: $this->repository->save($confirmedOrder);
return $confirmedOrder;
}
// ✅ Fix: Save updated order
public function confirmOrder(OrderId $orderId): Order
{
$order = $this->repository->findById($orderId);
$confirmedOrder = $order->confirm();
// Save updated order
$this->repository->save($confirmedOrder);
return $confirmedOrder;
}
Step 4: Update Tests
Enable and Verify Test:
describe('Bug #123: Orders not updating status', function () {
it('confirms order and persists status', function () {
$order = Order::create(/* ... */);
$confirmedOrder = $this->orderService->confirmOrder($order->id);
// Verify status is updated
expect($confirmedOrder->status)->toBe(OrderStatus::CONFIRMED);
// Verify persistence
$retrieved = $this->repository->findById($order->id);
expect($retrieved->status)->toBe(OrderStatus::CONFIRMED);
});
});
Run All Tests:
./vendor/bin/pest
Step 5: Regression Prevention
Add Integration Test:
it('persists order status changes', function () {
$order = Order::create(/* ... */);
$this->repository->save($order);
// Confirm order
$confirmed = $this->orderService->confirmOrder($order->id);
// Retrieve from database
$fromDb = $this->repository->findById($order->id);
expect($fromDb->status)->toBe(OrderStatus::CONFIRMED);
});
Step 6: Documentation
Update Changelog:
## [2.1.1] - 2025-01-28
### Fixed
- **Orders**: Fixed bug where order status was not persisted after confirmation (#123)
- Added missing repository save call in OrderService::confirmOrder()
- Added regression test to prevent future occurrence
Database Migration Workflow
Creating a Migration
# Generate migration file
docker exec php php console.php make:migration AddEmailVerifiedColumn Users
# Migration file created at:
# src/Domain/Users/Database/Migrations/2025_01_28_143000_AddEmailVerifiedColumn.php
Implement Migration:
namespace App\Domain\Users\Database\Migrations;
use App\Framework\Database\Migration\{Migration, SafelyReversible};
use App\Framework\Database\Schema\{Blueprint, Schema};
final readonly class AddEmailVerifiedColumn implements Migration, SafelyReversible
{
public function up(Schema $schema): void
{
$schema->table('users', function (Blueprint $table) {
// Add column
$table->boolean('email_verified')->default(false);
$table->timestamp('email_verified_at')->nullable();
// Add index for querying verified users
$table->index('email_verified');
});
}
public function down(Schema $schema): void
{
$schema->table('users', function (Blueprint $table) {
// Drop in reverse order
$table->dropIndex('email_verified');
$table->dropColumn('email_verified_at', 'email_verified');
});
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromString('2025_01_28_143000');
}
public function getDescription(): string
{
return 'Add email verification columns to users table';
}
}
Running Migrations
# Check migration status
docker exec php php console.php db:status
# Run pending migrations
docker exec php php console.php db:migrate
# Rollback last migration
docker exec php php console.php db:rollback
# Rollback multiple steps
docker exec php php console.php db:rollback 3
Migration Best Practices
Safe Rollbacks:
- Only implement
SafelyReversibleif rollback is data-safe - Document why a migration is not reversible if omitting
down()
Column Modifications:
// ✅ Safe: Adding nullable column
$table->string('new_field')->nullable();
// ⚠️ Requires data migration: Adding required column
$table->string('required_field'); // Existing rows will fail!
// ✅ Better: Add nullable, migrate data, then make required
// Migration 1: Add nullable
$table->string('required_field')->nullable();
// Migration 2 (data): Populate existing rows
// Migration 3 (schema): Make required
$table->string('required_field')->nullable(false)->change();
Performance Considerations:
// ⚠️ Slow: Adding index to large table
$table->index('email'); // Blocks table on large datasets
// ✅ Better: Use online algorithm (MySQL 5.7+)
$connection->statement(
'ALTER TABLE users ADD INDEX idx_email (email) ALGORITHM=INPLACE, LOCK=NONE'
);
Refactoring Workflow
Safe Refactoring Process
1. Ensure Test Coverage:
# Run tests before refactoring
./vendor/bin/pest --coverage
# Minimum 80% coverage for critical code paths
2. Make Small, Incremental Changes:
// ❌ Bad: Large refactoring in one step
// Refactor entire service layer at once
// ✅ Good: Incremental refactoring
// Step 1: Extract method
// Step 2: Move to new class
// Step 3: Update references
// Run tests after each step
3. Extract Method:
// Before
public function processOrder(Order $order): void
{
// Validate payment
if ($order->total->cents < 100) {
throw new MinimumOrderException();
}
// Process payment
$this->gateway->charge($order->total);
// Send confirmation
$this->mailer->send($order->user->email, 'Order confirmed');
// Update inventory
foreach ($order->items as $item) {
$this->inventory->reduce($item->productId, $item->quantity);
}
}
// After
public function processOrder(Order $order): void
{
$this->validateOrder($order);
$this->chargePayment($order);
$this->sendConfirmation($order);
$this->updateInventory($order);
}
private function validateOrder(Order $order): void
{
if ($order->total->cents < 100) {
throw new MinimumOrderException();
}
}
private function chargePayment(Order $order): void
{
$this->gateway->charge($order->total);
}
// ... etc
4. Extract Class:
// Before: God class with too many responsibilities
final readonly class OrderService
{
public function processOrder(Order $order): void { /* ... */ }
public function chargePayment(Order $order): void { /* ... */ }
public function sendConfirmation(Order $order): void { /* ... */ }
public function updateInventory(Order $order): void { /* ... */ }
}
// After: Separate concerns
final readonly class OrderService
{
public function __construct(
private readonly OrderProcessor $processor,
private readonly PaymentHandler $payment,
private readonly NotificationService $notifications,
private readonly InventoryUpdater $inventory
) {}
public function processOrder(Order $order): void
{
$this->processor->validate($order);
$this->payment->charge($order);
$this->notifications->sendConfirmation($order);
$this->inventory->update($order);
}
}
5. Run Tests After Each Step:
./vendor/bin/pest
Refactoring Patterns
Replace Primitive with Value Object:
// Before
final readonly class User
{
public function __construct(
public string $id,
public string $email,
public string $name
) {}
}
// After
final readonly class User
{
public function __construct(
public UserId $id,
public Email $email,
public UserName $name
) {}
}
Replace Magic Numbers:
// Before
if ($user->age >= 18) {
// Can vote
}
// After
final readonly class VotingAge
{
public const MINIMUM = 18;
}
if ($user->age >= VotingAge::MINIMUM) {
// Can vote
}
Performance Optimization Workflow
Step 1: Measure Current Performance
# Enable performance profiling
docker exec php php console.php performance:enable
# Profile endpoint
docker exec php php console.php performance:profile /api/users
# Check slow queries
docker exec php tail -f /var/log/mysql/slow-queries.log
Step 2: Identify Bottlenecks
Performance Profiling:
use App\Framework\Performance\PerformanceCollector;
$collector = new PerformanceCollector();
$collector->startMeasurement('expensive_operation');
// Expensive operation
$results = $this->heavyComputation();
$collector->endMeasurement('expensive_operation');
// View results
foreach ($collector->getMeasurements() as $measurement) {
Logger::info('[Performance]', [
'operation' => $measurement->name,
'duration' => $measurement->duration->toMilliseconds()
]);
}
Check N+1 Queries:
docker exec php php console.php db:detect-n-plus-one /api/users
Step 3: Optimize
Cache Expensive Operations:
use App\Framework\Cache\{CacheKey, CacheItem};
use App\Framework\Core\ValueObjects\Duration;
public function getExpensiveData(): array
{
$cacheKey = CacheKey::fromString('expensive_data');
return $this->cache->remember(
$cacheKey,
fn() => $this->computeExpensiveData(),
Duration::fromMinutes(10)
);
}
Fix N+1 Queries:
// ❌ Before: N+1 queries
$users = $this->userRepository->findAll();
foreach ($users as $user) {
$profile = $user->getProfile(); // Separate query each time
}
// ✅ After: Eager loading
$users = $this->userRepository->findAllWithProfiles();
// Single JOIN query
Add Database Indexes:
// Create migration
$schema->table('users', function (Blueprint $table) {
// Add index for frequently queried column
$table->index('email');
// Composite index for common WHERE clauses
$table->index(['status', 'created_at']);
});
Step 4: Verify Improvements
# Re-profile endpoint
docker exec php php console.php performance:profile /api/users
# Compare before/after metrics
docker exec php php console.php performance:compare before.json after.json
Performance Optimization Checklist
- Identify bottleneck (profiling data)
- Set performance target (e.g., <200ms response time)
- Implement optimization
- Measure improvement
- Verify no functional regression
- Document optimization
- Monitor in production
Best Practices
General Development Guidelines
1. Follow Framework Patterns:
- Use Value Objects instead of primitives
- Readonly classes by default
- Final classes (no inheritance)
- Explicit dependency injection
- Event-driven architecture
2. Security First:
- Validate all user input
- Use parameterized queries
- Implement authentication/authorization
- Never expose sensitive data in logs
- Follow OWASP guidelines
3. Performance Awareness:
- Profile before optimizing
- Cache expensive operations
- Avoid N+1 queries
- Use batch operations
- Monitor production metrics
4. Test Everything:
- Unit tests for domain logic
- Integration tests for API endpoints
- Edge case testing
- Regression tests for bug fixes
- Performance tests for critical paths
5. Documentation:
- Update API documentation
- Document complex business logic
- Maintain changelog
- Code should be self-documenting
Code Quality Checklist
Before Committing:
- Run
composer cs(code style check) - Run
./vendor/bin/pest(all tests pass) - Run
composer reload(autoloader updated) - Check for commented-out code
- Verify no debug statements left
- Update documentation
- Review your own changes first
Before Merging:
- All CI checks pass
- Code reviewed by peer
- No merge conflicts
- Migration tested
- Performance impact assessed
- Security implications considered
- Changelog updated
Development Environment Setup
Initial Setup:
# Clone repository
git clone <repository-url>
cd michaelschiemer
# Copy environment file
cp .env.example .env
# Start Docker containers
make up
# Install dependencies
composer install
npm install
# Run migrations
docker exec php php console.php db:migrate
# Build frontend assets
npm run build
# Verify setup
curl -k https://localhost/health
Daily Development:
# Start services
make up
# Watch frontend changes
npm run dev
# Run tests
./vendor/bin/pest --watch
# Check logs
make logs
Last Updated: 2025-01-28 Framework Version: 2.x