rcalicdan/slim-skeleton
最新稳定版本:1.0.0
Composer 安装命令:
composer create-project rcalicdan/slim-skeleton
包简介
Slim 4 skeleton with PHP-DI, Blade, and Pest
README 文档
README
A minimal, opinionated PHP micro-framework skeleton built on Slim 4. Wraps Slim's raw PSR-7 primitives with a Laravel-inspired developer experience, without pulling in the full Laravel ecosystem.
Whether you're building a traditional web app, an API, or something in between, this skeleton gives you a solid, testable foundation you can shape into any architecture you prefer.
Table of Contents
- Tech Stack
- Getting Started
- Directory Structure
- Choosing Your Architecture
- Architecture Deep-Dive
- Configuration
- HTTP Layer
- Validation
- Blade Templating
- Global Helper Functions
- Middleware Reference
- Session
- Testing
Tech Stack
| Package | Role |
|---|---|
slim/slim ^4 |
Router and middleware pipeline |
slim/psr7 |
PSR-7 HTTP message implementation |
php-di/php-di ^7 |
DI container with autowiring |
eftec/bladeone |
Blade-compatible template engine |
rcalicdan/config-loader |
config() and env() helpers |
odan/session |
PSR-15 session management |
somnambulist/validation |
Validation factory and rules |
pestphp/pest ^4 |
Test framework |
laravel/pint |
Code style (PSR-12 preset) |
phpstan/phpstan |
Static analysis (level 6) |
Getting Started
composer create-project rcalicdan/slim-skeleton my-app
cd my-app
cp .env.example .env
php -S localhost:8000 -t public
Visit http://localhost:8000 and you should see the skeleton welcome page.
Environment variables (.env):
APP_ENV=local # Set to "production" to enable container and blade caching APP_DEBUG=true # Set to false in production
Directory Structure
slim-skeleton/
├── app/
│ └── Controllers/ # Your application controllers (or handlers, actions, etc.)
├── config/
│ ├── blade.php # Template paths, cache mode, custom directives
│ ├── container.php # DI bindings and settings
│ ├── middleware.php # Global middleware registration
│ ├── routes.php # Route definitions
│ └── validation.php # Custom validation rule bindings
├── integrations/ # The framework integration layer; generally leave this alone
│ ├── Http/
│ │ ├── Exceptions/
│ │ │ └── ValidationException.php
│ │ ├── Middleware/
│ │ │ ├── ApiValidationMiddleware.php
│ │ │ ├── BindRequestMiddleware.php
│ │ │ ├── CsrfMiddleware.php
│ │ │ └── WebValidationMiddleware.php
│ │ ├── FormRequest.php
│ │ ├── Request.php
│ │ ├── Response.php
│ │ ├── ResponseFactory.php
│ │ └── ValidatedData.php
│ ├── View/
│ │ ├── Directives/
│ │ │ ├── EndErrorDirective.php
│ │ │ ├── ErrorDirective.php
│ │ │ └── UpperDirective.php
│ │ └── BladeRenderer.php
│ ├── functions.php # Global helpers (autoloaded)
│ └── Registry.php # Static container accessor
├── public/
│ └── index.php # Application entry point
├── templates/ # Blade template files (.blade.php)
├── tests/
│ ├── FrameworkIntegration/ # Skeleton's own integration tests; delete when starting your project
│ ├── Pest.php
│ └── TestCase.php # Base test case with HTTP helpers; keep this
└── .env
The app/ directory is yours entirely. The integrations/ layer is the glue between Slim and your application. You typically don't need to modify it unless you're extending the framework itself.
Choosing Your Architecture
The skeleton doesn't lock you into MVC. The app/ directory is a blank slate. Here are the most common patterns and how they fit.
MVC (Model-View-Controller)
The default approach. Controllers handle requests, Blade templates handle views, and your models live wherever makes sense (Eloquent, Doctrine, plain classes, etc.).
app/
├── Controllers/
│ └── UserController.php
├── Models/
│ └── User.php
└── Services/
└── UserService.php
// config/routes.php $app->get('/users/{id}', [UserController::class, 'show']); $app->post('/users', [UserController::class, 'store']);
ADR (Action-Domain-Responder)
One class per user action. Keeps each handler small and focused. PHP-DI's autowiring resolves dependencies automatically, so no manual registration is needed.
app/
├── Actions/
│ └── User/
│ ├── ShowUserAction.php
│ ├── StoreUserAction.php
│ └── DeleteUserAction.php
├── Domain/
│ └── User/
│ ├── UserRepository.php
│ └── UserService.php
└── Responders/
└── UserResponder.php
// config/routes.php $app->get('/users/{id}', ShowUserAction::class); $app->post('/users', StoreUserAction::class); $app->delete('/users/{id}', DeleteUserAction::class); // app/Actions/User/ShowUserAction.php class ShowUserAction { public function __construct(private readonly UserRepository $users) {} public function __invoke(Request $request, Response $response): Response { $user = $this->users->findOrFail($request->route('id')); return $response->view('users.show', compact('user')); } }
Service-Oriented / API-only
Skip Blade entirely and return JSON from every route. Useful when this app is a backend for a JavaScript frontend.
app/
├── Controllers/
│ └── Api/
│ └── UserController.php
└── Services/
└── UserService.php
return $response->json([ 'data' => $user, 'meta' => ['version' => '1.0'], ]);
Swap WebValidationMiddleware for ApiValidationMiddleware globally in config/middleware.php and you're done.
Functional / Closure-based
Great for very small apps or rapid prototyping. Define everything inline in config/routes.php.
$app->get('/ping', function (Request $request, Response $response): Response { return $response->json(['pong' => true]); }); $app->post('/echo', function (Request $request, Response $response): Response { $validated = $request->validate(['message' => 'required|string']); return $response->json($validated->all()); });
The skeleton provides the plumbing. What you build on top is entirely up to you.
Architecture Deep-Dive
Bootstrap Flow
public/index.php is the single entry point. The startup sequence runs as follows:
1. Build PHP-DI ContainerBuilder
└── Load settings from config/container.php
└── Optionally enable compiled container (production)
2. Registry::set($container) ← makes container globally accessible
3. AppFactory::create($responseFactory)
4. $container->set(App::class, $app)
5. BladeRenderer::init(...) ← initializes Blade renderer singleton
6. Load config/middleware.php ← registers global middleware
7. Load config/routes.php ← registers routes
8. Request::createFromGlobals() ← wraps PHP superglobals
9. $app->run($request)
In production (APP_ENV=production):
- PHP-DI compiles the container to
var/cache/difor a faster boot. - BladeOne uses
MODE_FAST, skipping file change checks on templates.
In local:
- No container compilation.
- BladeOne uses
MODE_AUTO, recompiling templates whenever they change.
Middleware Execution Order
Slim processes middleware in LIFO (Last In, First Out) order. The middleware added last in config/middleware.php runs first when a request arrives.
Registration order (in middleware.php) Actual execution order
───────────────────────────────────── ───────────────────────────────────────
addBodyParsingMiddleware() ErrorMiddleware (outermost)
RoutingMiddleware └── SessionStartMiddleware
MethodOverrideMiddleware └── CsrfMiddleware
BindRequestMiddleware └── WebValidationMiddleware
WebValidationMiddleware └── BindRequestMiddleware
CsrfMiddleware └── MethodOverrideMiddleware
SessionStartMiddleware └── RoutingMiddleware
addErrorMiddleware() ← last └── Controller
Why this order matters:
SessionStartbeforeCsrf, because CSRF reads and writes the session token.MethodOverridebeforeRouting, so the router sees the spoofed method (PUT/DELETE) rather than the rawPOST.BindRequestafterMethodOverride, binding the already-corrected request to the container.ErrorMiddlewareoutermost, catching any unhandled exception from the entire pipeline.
DI Container and Registry
PHP-DI is the container. Bindings live in config/container.php under dependency_map.
Registry is a static accessor that holds the container instance. It exists so that global helper functions, which can't receive injected arguments, can still reach container-managed services.
// Stored once in public/index.php Registry::set($container); // Accessible anywhere $container = Registry::get(); $session = $container->get(SessionInterface::class);
Prefer constructor injection in controllers and services. Only reach for
Registry::get()in global functions or static contexts where injection isn't possible.
Controllers, FormRequest subclasses, and single-action handlers are autowired, so no manual registration is needed.
Configuration
All config files return a plain PHP array (or a callable for middleware/routes), accessed via the config('file.key') helper.
container.php
Controls PHP-DI settings and all explicit service bindings.
'settings' => [ 'autowire' => true, // Enable autowiring 'use_attributes' => true, // Enable #[Inject] attribute 'cache_path' => null, // Point to a path in production to compile the container ], 'dependency_map' => [ // Add your own service bindings here MyService::class => function (ContainerInterface $c) { return new MyService($c->get(SomeDependency::class)); }, ],
Pre-bound services: PhpSession, SessionManagerInterface, SessionInterface, ResponseFactoryInterface, RouteParserInterface, BladeOne, BladeRenderer, ValidationFactory.
middleware.php
Returns function(App $app): void. Add your own global middleware here, keeping LIFO order in mind.
// Runs after routing but before the controller: $app->add(MyCustomMiddleware::class); // Add this before the BindRequestMiddleware line
routes.php
Returns function(App $app): void.
return function (App $app): void { $app->get('/', [HomeController::class, 'index']); // Named route $app->get('/users/{id}', [UserController::class, 'show'])->setName('users.show'); // Route group $app->group('/api', function (RouteCollectorProxy $group) { $group->get('/ping', PingController::class); })->add(ApiValidationMiddleware::class); };
blade.php
| Key | Description |
|---|---|
templates_path |
Where .blade.php files live (templates/ by default) |
cache_path |
Where compiled .bladec files are stored |
mode |
MODE_AUTO (local) / MODE_FAST (production) |
directives |
Compile-time directives as ['name' => callable|class-string] |
directives_rt |
Run-time directives as ['name' => callable|class-string] |
validation.php
Register custom validation rule classes to use as strings in rule arrays:
'rules' => [ 'strong_password' => App\Rules\StrongPasswordRule::class, ],
The rule class is resolved from the DI container, so constructor injection works. See Custom Validation Rules for full usage.
HTTP Layer
Request
Integrations\Http\Request extends Slim\Psr7\Request.
Static Factories
| Method | Description |
|---|---|
Request::createFromGlobals() |
Production use; wraps PHP superglobals |
Request::createTestRequest(string $method, string $uri) |
Test use; creates a mock request |
Instance Methods
| Method | Signature | Description |
|---|---|---|
input |
(string $key, mixed $default = null): mixed |
Parsed body first, falls back to query string |
query |
(string $key, mixed $default = null): mixed |
Query string only |
has |
(string $key): bool |
True if key exists in body or query, even if empty string |
filled |
(string $key): bool |
True if key exists and is not null or '' |
route |
(string $key, mixed $default = null): mixed |
A route segment parameter like {id} |
allRouteArgs |
(): array |
All route segment parameters as ['key' => 'value'] |
url |
(): string |
Current URL without query string |
fullUrl |
(): string |
Current URL including query string |
previousUrl |
(string $fallback = '/'): string |
Value of the Referer header |
validate |
(array|string|FormRequest $rules): ValidatedData |
See Validation |
Response
Integrations\Http\Response extends Slim\Psr7\Response.
| Method | Signature | Description |
|---|---|---|
json |
(mixed $data, int $status = 0): self |
JSON body with Content-Type: application/json. Status defaults to the current status code when 0. |
html |
(string $html, int $status = 200): self |
Raw HTML body |
view |
(string $template, array $data = [], int $status = 200): self |
Renders a Blade template |
redirect |
(string $url, int $status = 302): self |
Redirect to a URL |
routeRedirect |
(string $routeName, array $data = [], array $queryParams = [], int $status = 302): self |
Redirect to a named route |
back |
(string $fallback = '/', int $status = 302): self |
Redirect to the Referer URL |
All response methods return a new immutable instance. Always
returnor assign the result.
ResponseFactory
Integrations\Http\ResponseFactory implements ResponseFactoryInterface.
$response = $factory->createResponse(201, 'Created Successfully'); // Returns Integrations\Http\Response, not Slim's base Response
This is bound to ResponseFactoryInterface in the container, so Slim always creates your custom Response objects internally.
ValidatedData
The immutable return value of any validate() call.
| Method | Signature | Description |
|---|---|---|
get |
(string $key, mixed $default = null): mixed |
Single value |
has |
(string $key): bool |
Key existence check |
only |
(string ...$keys): array |
Subset of keys |
except |
(string ...$keys): array |
All keys except those listed |
all |
(): array |
Full validated array |
toArray |
(): array |
Alias of all() |
Validation
The validation engine is somnambulist/validation. Rules use a pipe-delimited string format like 'required|email|min:5', or an array of rule objects.
Inline Validation
Validate directly on the request object with a rules array. Best for simple, one-off validations in closures or small controllers.
public function store(Request $request, Response $response): Response { $validated = $request->validate([ 'email' => 'required|email', 'username' => 'required|min:3|max:30', 'age' => 'required|integer|min:18', ]); $email = $validated->get('email'); return $response->json($validated->all()); }
On failure, ValidationException is thrown. WebValidationMiddleware catches it for web routes and redirects back with session errors. ApiValidationMiddleware catches it for API routes and returns a 422 JSON response.
FormRequest
Create a class extending FormRequest for reusable, encapsulated validation. Best for complex forms or when you want to keep controllers thin.
// app/Http/Requests/StoreUserRequest.php namespace App\Http\Requests; use Integrations\Http\FormRequest; class StoreUserRequest extends FormRequest { public function rules(): array { return [ 'name' => 'required|string|min:2', 'email' => 'required|email', ]; } // Optional: custom error messages public function messages(): array { return [ 'email:required' => 'An email address is mandatory.', ]; } // Optional: rename fields in error messages public function attributes(): array { return ['email' => 'email address']; } // Optional: mutate input before validation runs public function prepareForValidation(array $data): array { $data['name'] = trim($data['name'] ?? ''); return $data; } // Optional: run logic after validation passes, before data is returned public function after(array $validated): array { $validated['slug'] = strtolower(str_replace(' ', '-', $validated['name'])); return $validated; } }
Using a FormRequest in a controller:
// Option A: pass the class-string to $request->validate() public function store(Request $request, Response $response): Response { $validated = $request->validate(StoreUserRequest::class); return $response->json($validated->all()); } // Option B: type-hint it directly; PHP-DI autowires and injects it public function store(StoreUserRequest $form, Response $response): Response { $validated = $form->validate(); return $response->json($validated->all()); }
Building conditional rules using the built-in pass-through helpers (input(), query(), route(), has(), filled(), getMethod()):
public function rules(): array { $rules = ['name' => 'required|string']; // Add a rule only when a field is present and non-empty if ($this->filled('company')) { $rules['tax_id'] = 'required|string'; } // Add a rule based on the HTTP method if ($this->getMethod() === 'POST') { $rules['email'] = 'required|email'; } // Add a rule based on a route parameter if ($this->route('id') !== null) { $rules['password'] = 'sometimes|min:8'; } return $rules; }
Custom Validation Rules
There are two ways to use a custom rule and you can mix them freely.
Option A: Registered String Rule
Best when you want to reference the rule by name across many FormRequests or inline validations, and when the rule needs injected dependencies like a database connection.
Step 1: Create the rule class.
A rule must implement Somnambulist\Components\Validation\Contracts\Rule or extend Somnambulist\Components\Validation\Rules\AbstractRule.
// app/Rules/UniqueEmailRule.php namespace App\Rules; use Somnambulist\Components\Validation\Rules\AbstractRule; class UniqueEmailRule extends AbstractRule { protected string $message = 'The :attribute is already taken.'; public function __construct(private readonly UserRepository $users) {} public function check(mixed $value): bool { return ! $this->users->existsByEmail($value); } }
Step 2: Register it in config/validation.php.
'rules' => [ 'unique_email' => App\Rules\UniqueEmailRule::class, ],
Step 3: Use it anywhere by its string name.
// Inline on a request $request->validate([ 'email' => 'required|email|unique_email', ]); // In a FormRequest public function rules(): array { return [ 'email' => 'required|email|unique_email', ]; }
Because the class is resolved from the DI container, UserRepository and any other dependency is injected automatically.
Option B: Inline Rule Object
Best for one-off rules you don't need to reuse elsewhere, or rules with no dependencies. No registration needed. Just instantiate and pass it directly.
// app/Rules/StrongPasswordRule.php namespace App\Rules; use Somnambulist\Components\Validation\Rules\AbstractRule; class StrongPasswordRule extends AbstractRule { protected string $message = 'The :attribute must contain uppercase, lowercase, and a number.'; public function check(mixed $value): bool { return preg_match('/[A-Z]/', $value) && preg_match('/[a-z]/', $value) && preg_match('/[0-9]/', $value); } }
Pass an instance directly in any rule array, mixing string rules and rule objects freely:
use App\Rules\StrongPasswordRule; // Inline on a request $request->validate([ 'password' => ['required', 'min:8', new StrongPasswordRule()], ]); // In a FormRequest public function rules(): array { return [ 'password' => ['required', 'min:8', new StrongPasswordRule()], ]; }
Comparison
| String Rule (Option A) | Inline Object (Option B) | |
|---|---|---|
| Registration required | Yes (config/validation.php) |
No |
| DI / constructor injection | Yes, resolved from container | Manual via new |
| Reusable across requests | Yes, by name | Yes, but must instantiate each time |
| Best for | Rules with dependencies, used in many places | Simple one-off rules |
IDOR Protection
FormRequest::validate() automatically strips route segment arguments from the returned ValidatedData. This prevents a client from overwriting URL parameters through the request body, a common parameter pollution and IDOR vector.
// Route: POST /users/{id} // Attacker sends body: { "id": 99, "name": "Hacker" } $validated = $request->validate(UpdateUserRequest::class); $validated->get('id'); // null, stripped by the framework $request->route('id'); // '5', the real URL parameter; always use this
This is enforced at the framework level. You never have to remember to handle it manually.
Blade Templating
Rendering a View
In a controller:
return $response->view('home', ['title' => 'My App']); // Renders templates/home.blade.php with $title available
Via global helper, useful outside of controllers:
$response = blade_view('home', ['title' => 'My App'], $response);
Templates live in templates/ with a .blade.php extension. Nested templates use dot notation: users.show resolves to templates/users/show.blade.php.
Built-in Directives
@csrf
Outputs a hidden CSRF token input. Required in every HTML form that mutates state.
<form action="/submit" method="POST"> @csrf ... </form>
Renders as:
<input type='hidden' name='_token' value='abc123...'/>
The token is generated by CsrfMiddleware on GET requests and stored in the session.
@method('VERB')
Outputs a hidden _METHOD input to spoof HTTP verbs from HTML forms. Use this when you need PUT, PATCH, or DELETE from a standard <form>.
<form action="/users/5" method="POST"> @csrf @method('PUT') ... </form>
Renders as:
<input type="hidden" name="_METHOD" value="PUT"/>
MethodOverrideMiddleware reads this field before routing, so your PUT and DELETE route definitions work as expected.
@error('field') / @enderror
Renders content only if a validation error exists for the given field. Sets $message to the first error string for that field inside the block.
<input type="email" name="email" value="{{ old('email') }}" class="input @error('email') is-invalid @enderror" > @error('email') <p class="error-text">{{ $message }}</p> @enderror
@upper($expression)
Compile-time directive. Outputs the expression in uppercase.
@upper('hello') {{-- HELLO --}} @upper($username) {{-- e.g. JOHN --}}
Custom Compile-time Directives
Compile-time directives run once when a template is compiled to cache. The callback returns a string of PHP code.
Option A: Closure directly in config/blade.php:
'directives' => [ 'money' => function (string $expression): string { return "<?php echo '$' . number_format((float) {$expression}, 2); ?>"; }, ],
Option B: Invokable class resolved via DI, supporting constructor injection:
// config/blade.php 'directives' => [ 'money' => \App\View\Directives\MoneyDirective::class, ], // app/View/Directives/MoneyDirective.php class MoneyDirective { public function __invoke(string $expression): string { return "<?php echo '$' . number_format((float) {$expression}, 2); ?>"; } }
Usage:
@money(1500.5) {{-- $1,500.50 --}} @money($price)
Custom Run-time Directives
Run-time directives execute on every page load. They receive fully-evaluated PHP values and output directly via echo.
Option A: Closure:
'directives_rt' => [ 'greet' => function (string $username): void { echo 'Hello, ' . htmlspecialchars($username); }, ],
Option B: Invokable class:
'directives_rt' => [ 'greet' => \App\View\Directives\GreetDirective::class, ],
Usage:
@greet($user->name)
Use compile-time for pure value transformations like formatting and escaping. Use run-time when you need live state: session data, current user, active services, and so on.
Global Helper Functions
All helpers are autoloaded from integrations/functions.php.
| Helper | Signature | Description |
|---|---|---|
blade_view |
(string $template, array $data = [], ?ResponseInterface $response = null): ResponseInterface |
Render a Blade template |
cache_path |
(string $path): string |
Creates the directory if missing and returns the path |
session |
(?string $key = null, mixed $default = null): mixed |
Returns the session instance with no key, or a session value |
old |
(string $key, mixed $default = null): mixed |
Previous form input after a failed validation |
error |
(string $key): ?string |
First error message for a field |
has_error |
(string $key): bool |
True if a field has a validation error |
error_all |
(): array |
All errors as ['field' => 'message'] |
route |
(string $routeName, array $data = [], array $queryParams = []): string |
URL for a named route |
current_url |
(bool $withQuery = false): string |
Current request URL |
previous_url |
(string $fallback = '/'): string |
Referer URL |
method_field |
(string $method): string |
Hidden _METHOD input HTML; prefer @method() in Blade templates |
Common usage in templates:
<input name="email" value="{{ old('email', '') }}"> @error('email') {{ $message }} @enderror <a href="{{ route('users.show', ['id' => $user->id]) }}">Profile</a> <p>You are at: {{ current_url() }}</p>
Middleware Reference
BindRequestMiddleware
Binds the active Request object into the DI container after MethodOverrideMiddleware has run. This makes current_url(), previous_url(), and $response->back() work anywhere in the app.
No configuration needed. Already registered globally.
CsrfMiddleware
On GET / HEAD / OPTIONS requests, it generates a _token in the session if one doesn't exist yet. On POST / PUT / PATCH / DELETE requests, it reads _token from the parsed body and compares it to the session value, returning a 403 JSON response on mismatch.
Always use @csrf in your forms. The token is included and verified automatically.
For API routes where CSRF isn't relevant, register ApiValidationMiddleware at the route group level instead of relying on the global middleware stack.
WebValidationMiddleware
Designed for traditional web routes. When a ValidationException is thrown anywhere downstream, it:
- Stores
errorsin the session as['field' => 'first error message']. - Stores
oldinput in the session for repopulating form fields. - Adds a flash message under the
errorkey. - Redirects back to the
Refererheader, falling back to/if none is present.
After a successful request, it automatically clears errors and old from the session.
Access these in templates via old('field'), error('field'), has_error('field'), and error_all().
ApiValidationMiddleware
Designed for API routes. Catches ValidationException and returns a structured 422 JSON response instead of redirecting.
{
"message": "The given data was invalid.",
"errors": {
"email": "The email field is required.",
"name": "The name field must be at least 2 characters."
}
}
Register it on API route groups:
$app->group('/api', function (RouteCollectorProxy $group) { $group->post('/users', [UserController::class, 'store']); $group->put('/users/{id}', [UserController::class, 'update']); })->add(ApiValidationMiddleware::class);
Session
The session is provided by odan/session. In production it uses PhpSession. In tests it uses MemorySession, so no real PHP session is started.
Via the session() helper:
session('key'); // get a value session()->set('key', 'value'); // set a value session()->delete('key'); // delete a key session()->has('key'); // check existence
Via constructor injection:
use Odan\Session\SessionInterface; class MyController { public function __construct(private readonly SessionInterface $session) {} public function index(Request $request, Response $response): Response { $userId = $this->session->get('user_id'); // ... } }
Flash messages:
// Set a flash message session()->getFlash()->add('success', 'Your changes were saved.'); // Read it on the next request, after a redirect $messages = session()->getFlash()->get('success'); // ['Your changes were saved.']
Testing
Tests use Pest PHP. Run them with:
composer test
Before You Start: Clean Up the Skeleton Tests
The tests/FrameworkIntegration/ directory contains tests that verify the skeleton's own wiring: CSRF, middleware, Blade directives, the HTTP layer, and so on. They exist to prove the skeleton works correctly out of the box.
Once you start building your own application, delete this directory:
rm -rf tests/FrameworkIntegration
Keep tests/TestCase.php and tests/Pest.php. Those are your testing foundation. Then write your own tests using whatever structure you prefer:
tests/
├── Feature/
│ ├── UserRegistrationTest.php
│ └── AuthenticationTest.php
├── Unit/
│ └── UserServiceTest.php
├── Pest.php
└── TestCase.php ← keep this
TestCase HTTP Helpers
The base TestCase in tests/TestCase.php:
- Boots a full application instance with a real DI container per test.
- Replaces
PhpSessionwithMemorySession, so no actual PHP session is started. - Loads middleware and routes from your config files, making these true integration tests.
- Auto-injects a valid CSRF token on all state-changing requests.
| Method | Description |
|---|---|
$this->get(string $path) |
GET request |
$this->post(string $path, array $data = []) |
POST; auto-injects _token |
$this->put(string $path, array $data = []) |
PUT; auto-injects _token |
$this->patch(string $path, array $data = []) |
PATCH; auto-injects _token |
$this->delete(string $path, array $data = []) |
DELETE; auto-injects _token |
$this->request(string $method, string $path, array $data = []) |
Raw request with no CSRF injection |
All helpers return Integrations\Http\Response.
Writing Tests
Basic route test:
it('shows the user profile page', function () { $response = $this->get('/users/1'); expect($response->getStatusCode())->toBe(200) ->and((string) $response->getBody())->toContain('John Doe'); });
Register a test-only route inside a test:
it('validates and returns data', function () { $this->app->post('/test', function (Request $request, Response $response) { $validated = $request->validate(['name' => 'required|string|min:2']); return $response->json($validated->all()); }); $response = $this->post('/test', ['name' => 'Alice']); $data = json_decode((string) $response->getBody(), true); expect($response->getStatusCode())->toBe(200) ->and($data['name'])->toBe('Alice'); });
Testing a validation failure on a web route, expecting a redirect:
it('redirects back with session errors on failed validation', function () { $this->app->post('/submit', function (Request $request, Response $response) { $request->validate(['email' => 'required|email']); return $response->json(['ok' => true]); }); $response = $this->post('/submit', ['email' => 'not-an-email']); expect($response->getStatusCode())->toBe(302); $session = $this->container->get(SessionInterface::class); expect($session->get('errors'))->toHaveKey('email'); });
Testing a validation failure on an API route, expecting 422 JSON:
it('returns 422 json on api validation failure', function () { $this->app->post('/api/users', function (Request $request, Response $response) { $request->validate(['email' => 'required|email']); return $response->json(['ok' => true]); })->add(ApiValidationMiddleware::class); $response = $this->request('POST', '/api/users', ['email' => 'bad']); $data = json_decode((string) $response->getBody(), true); expect($response->getStatusCode())->toBe(422) ->and($data['errors'])->toHaveKey('email'); });
Interacting with the session directly in a test:
it('reads a value the controller stored in session', function () { $this->app->post('/login', function (Request $request, Response $response) { session()->set('user_id', 42); return $response->json(['ok' => true]); }); $this->post('/login', []); $session = $this->container->get(SessionInterface::class); expect($session->get('user_id'))->toBe(42); });
Testing method spoofing:
it('routes spoofed PUT requests correctly', function () { $this->app->put('/users/{id}', function (Request $request, Response $response) { return $response->json(['id' => $request->route('id')]); }); $response = $this->post('/users/5', ['_METHOD' => 'PUT']); $data = json_decode((string) $response->getBody(), true); expect($response->getStatusCode())->toBe(200) ->and($data['id'])->toBe('5'); });
$this->containeris typed asDI\Container, so you can call$this->container->set()to override bindings mid-test for mocking.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-11