- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
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