| title | intent | tags | prereqs | complexity | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
Slim 4 Best Practices |
Guidance for Slim 4 app setup, routing, middleware, and error handling |
|
|
intermediate |
ChurchCRM uses Slim 4 for API routes and modern MVC features. This skill covers setup, routing, middleware, response handling, and dependency injection patterns.
// Entry point: src/api/index.php or src/finance/index.php
use Slim\Factory\AppFactory;
use Slim\Views\PhpRenderer;
use DI\Container;
$container = new Container();
$container->set('view', fn() => new PhpRenderer($viewDir));
AppFactory::setContainer($container);
$app = AppFactory::create();
// Middleware (LIFO order - last added runs FIRST!)
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
$app->add(new CorsMiddleware()); // Last added, runs first
$app->add(new AuthMiddleware());
$app->add(new VersionMiddleware()); // First added, runs last
// Routes
$app->group('/api', function(RouteCollectorProxy $group) {
$group->get('/endpoint', fn($req, $res) => $res->withJson($data));
});
$app->run();Slim 4 uses Last In, First Out (LIFO) ordering. Middleware added LAST executes FIRST:
Order Added Execution Order
1. addBodyParsingMiddleware() → 4th (processes body after routing)
2. addRoutingMiddleware() → 3rd (routes request)
3. add(CorsMiddleware) → 1st (first middleware to run)
4. add(AuthMiddleware) → 2nd (runs after CORS)
5. add(VersionMiddleware) → Last added, runs last
Why it matters:
- Auth should run before routes (check permissions)
- CORS should run first (allow/deny early)
- Body parsing last (only needed after routing)
Always add a comment documenting the actual execution order (not the add order) when the middleware stack is non-obvious. This prevents future developers from accidentally mis-ordering security/redirect middleware:
// ✅ CORRECT - Document execution order, not add order
// Execution order: VersionMiddleware → AuthMiddleware → ChurchInfoRequiredMiddleware → CorsMiddleware
$app->add(new CorsMiddleware()); // Added 1st, runs LAST
$app->add(new ChurchInfoRequiredMiddleware()); // Added 2nd, runs 3rd
$app->add(AuthMiddleware::class); // Added 3rd, runs 2nd
$app->add(VersionMiddleware::class); // Added 4th, runs FIRST
// ❌ WRONG - Documents add order instead of execution order
// This causes confusion and security bugs when middleware is reordered
// Execution order: CorsMiddleware → ChurchInfoRequiredMiddleware → AuthMiddleware → VersionMiddleware// Apply middleware to entire group
$app->group('/admin', function(RouteCollectorProxy $group) {
$group->get('/users', UserController::class . ':list');
$group->post('/users', UserController::class . ':create');
$group->get('/users/{id}', UserController::class . ':show');
})->add(AdminRoleAuthMiddleware::class);$app->group('/api', function(RouteCollectorProxy $group) {
$group->group('/admin', function(RouteCollectorProxy $adminGroup) {
$adminGroup->post('/config', ConfigController::class . ':update');
$adminGroup->get('/logs', LogController::class . ':list');
})->add(AdminRoleAuthMiddleware::class);
$group->group('/finance', function(RouteCollectorProxy $finGroup) {
$finGroup->get('/reports', ReportController::class . ':index');
})->add(FinanceRoleAuthMiddleware::class);
});$app->get('/person/{id}', function($req, $res, $args) {
$personId = (int)$args['id']; // Always cast to int
$person = PersonQuery::create()->findOneById($personId);
if ($person === null) {
return $res->withStatus(404)->withJson(['error' => 'Not found']);
}
return $res->withJson(['data' => $person->toArray()]);
});use ChurchCRM\Slim\SlimUtils;
return SlimUtils::renderJSON($response, [
'data' => $result,
'message' => 'Operation successful'
]);try {
$result = doWork();
return SlimUtils::renderJSON($response, ['data' => $result]);
} catch (ValidationException $e) {
return SlimUtils::renderErrorJSON(
$response,
gettext('Validation failed'),
['errors' => $e->getErrors()],
400,
$e,
$request
);
} catch (Throwable $e) {
return SlimUtils::renderErrorJSON(
$response,
gettext('Operation failed'),
[],
500,
$e,
$request
);
}$finfo = new \finfo(FILEINFO_MIME_TYPE);
$contentType = trim($finfo->file($filePath)) ?: 'application/octet-stream';
return $response
->withHeader('Content-Type', $contentType)
->withHeader('Content-Disposition', 'attachment; filename="' . basename($filePath) . '"')
->withBody(new \Slim\Psr7\Stream(fopen($filePath, 'rb')));use Slim\Views\PhpRenderer;
$view = $container->get('view');
return $view->render($response, 'users.php', [
'sRootPath' => SystemURLs::getRootPath(),
'sPageTitle' => gettext('Users'),
'users' => $allUsers,
'stats' => $userStats,
]);Pattern:
class UserService {
public function __construct(
private UserRepository $userRepo,
private LoggerInterface $logger
) {}
public function createUser($data): User {
$this->logger->info('Creating user', ['email' => $data['email']]);
return $this->userRepo->save($data);
}
}
// Register in container
$container->set('UserService', fn(Container $c) => new UserService(
$c->get('UserRepository'),
$c->get('LoggerInterface')
));
// Use in routes
$app->post('/users', function($request, $response) use ($container) {
$service = $container->get('UserService');
$user = $service->createUser($request->getParsedBody());
return SlimUtils::renderJSON($response, ['data' => $user]);
});Key Points:
- NEVER use global
$containerdirectly - Always inject dependencies via constructor
- Register services in container at startup
- Use type hints for IDE support
// ✅ CORRECT
$group->post('/endpoint', function($request, $response, $args) {
$data = $request->getParsedBody();
return $response->withJson(['success' => true]);
});
// ❌ WRONG - String references don't work in Slim 4
$group->post('/endpoint', 'ControllerClass::method');// ✅ CORRECT - Log but don't crash
if (!mail($to, $subject, $body)) {
error_log("Email failed to " . $to);
}
return $response->withJson(['data' => $result, 'message' => 'Created']);
// ❌ WRONG - Throws 500 error
if (!mail($to, $subject, $body)) {
throw new Exception("Email failed");
}// ✅ CORRECT - Null coalescing
echo $notification?->title ?? 'No Title';
// ❌ WRONG - TypeError if null
echo $notification->title;ALWAYS use SlimUtils::renderErrorJSON() for API errors:
// Function signature
SlimUtils::renderErrorJSON(
Response $response, // Original $response object
?string $message = null, // User-facing message (localized)
array $extra = [], // Extra data for response
int $status = 500, // HTTP status code
?\Throwable $exception = null, // Exception for logging
?Request $request = null // Request for context logging
): ResponseBehavior:
- Server-side: Logs full exception details (trace, file, line)
- Client receives: Sanitized message only (no traces, file paths, credentials)
- Message is automatically masked for sensitive patterns
- Never throws exceptions from route handlers
- Routing & Middleware: See Admin System Pages section in copilot-instructions.md
- Authorization: See authorization-security.md skill
- API Development: See api-development.md skill