ShiftPHP Documentation
ShiftPHP is a small, zero-dependency, API-only framework. It is built around modules, attribute routing, typed request DTOs, a native PDO database layer, and a lightweight CLI.
This documentation describes the current framework codebase and the example Health module shipped in this repository.
Installation
ShiftPHP requires PHP 8.3 or newer, Composer, and the json and pdo PHP extensions.
composer install
cp .env.example .env
php -S 127.0.0.1:8000 index.php
Visit the example endpoint:
curl http://127.0.0.1:8000/health
Run the framework checks:
./shift doctor
./shift qa
Configuration
The bootstrap file loads .env from the project root. Existing server environment variables are not overwritten.
APP_ENV=local
LOG_ENABLED=false
LOG_PATH=storage/logs/shift.log
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=shift
DB_USERNAME=root
DB_PASSWORD=
DB_CHARSET=utf8mb4
| Variable | Purpose |
|---|---|
APP_ENV |
Application environment label shown by diagnostics. |
LOG_ENABLED |
Enables JSON-line exception logs when set to true, 1, yes, or on. |
LOG_PATH |
Relative or absolute path for the file logger. |
DB_CONNECTION |
Database driver. Supported values include mysql and sqlite. |
Directory Structure
The framework lives in src/. Application code lives in application/, and modules live in application/modules.
.
|-- application/
| `-- modules/
| `-- Health/
|-- database/
| `-- migrations/
|-- docs/
|-- src/
|-- storage/
| |-- cache/
| `-- logs/
|-- tests/
|-- index.php
`-- shift
Request Lifecycle
The HTTP entry point creates a request, creates the app, loads modules, registers services and routes, then starts the app.
use Shift\App;
use Shift\Modules\ModuleLoader;
use Shift\Request;
require_once 'bootstrap.php';
$request = new Request();
$app = new App($request);
$modules = (new ModuleLoader())->load();
$modules->registerServices($app->getContainer());
$modules->registerRoutes($app->getRouter());
$modules->boot($app->getContainer());
$app->start();
The internal flow is intentionally small:
Request
-> Shift\App
-> Middleware pipeline
-> Router
-> Controller action
-> Response
-> ResponseEmitter
Routing
Routes are usually declared with PHP attributes on module-owned controllers and loaded with Shift\Routing\AttributeRouteLoader.
namespace Modules\Users\Controllers;
use Shift\Controller;
use Shift\Routing\Attributes\Get;
use Shift\Routing\Attributes\Post;
use Shift\Routing\Attributes\RoutePrefix;
#[RoutePrefix('/users')]
final class UserController extends Controller
{
#[Get('/{id}')]
public function show(int $id): array
{
return ['id' => $id];
}
#[Post('')]
public function store(): array
{
return ['created' => true];
}
}
Available Attributes
| Attribute | Used on | Description |
|---|---|---|
#[RoutePrefix('/api')] | Class | Prefixes all controller routes. |
#[Get('/path')] | Method | Registers a GET route. |
#[Post('/path')] | Method | Registers a POST route. |
#[Put('/path')] | Method | Registers a PUT route. |
#[Patch('/path')] | Method | Registers a PATCH route. |
#[Delete('/path')] | Method | Registers a DELETE route. |
#[Status(201)] | Method | Overrides the response status. |
#[Header('X-Name', 'value')] | Method | Adds a response header. |
Controllers
Controllers extend Shift\Controller. They are resolved through the service container, so typed constructor dependencies are autowired.
use Shift\Controller;
use Shift\Response\JsonResponse;
use Shift\Routing\Attributes\Get;
use Shift\Routing\Attributes\PathParam;
final class UserController extends Controller
{
public function __construct(private readonly UserService $users)
{
}
#[Get('/users/{id}')]
public function show(#[PathParam] int $id): JsonResponse
{
return $this->json($this->users->find($id));
}
}
Controller actions may return Response, JsonResponse, arrays, scalars, or null. Arrays are converted to JSON responses and null becomes a 204 No Content response.
Requests
Shift\Request wraps server data, query params, post data, headers, route params, attributes, and JSON request bodies.
$request->getMethod();
$request->getPath();
$request->query('page', 1);
$request->post('name');
$request->input('name');
$request->getJson();
$request->getHeader('Authorization');
$request->getRequestId();
$request->routeParam('id');
If the incoming request does not contain X-Request-Id, ShiftPHP generates one. Every response receives the same X-Request-Id header, and structured exception logs include it.
Calling getJson() on malformed JSON throws an HTTP error and the app returns a 400 Bad Request JSON response.
Responses
Use controller helpers for common API responses.
return $this->json(['status' => 'ok']);
return $this->json($payload, 201);
return $this->error('Invalid payload', 422);
return $this->noContent();
JsonResponse automatically sets Content-Type: application/json.
Validation
Request DTOs extend Shift\Validation\RequestDto and declare rules with a static rules() method.
use Shift\Validation\RequestDto;
final class CreateUserDto extends RequestDto
{
public function __construct(
public readonly string $email,
public readonly int $age
) {
}
public static function rules(): array
{
return [
'email' => 'required|string|email',
'age' => 'required|int|min:18',
];
}
}
Bind DTOs explicitly with #[BodyDto], or type-hint a RequestDto subclass in an action.
#[Post('/users')]
public function store(#[BodyDto] CreateUserDto $dto): array
{
return ['email' => $dto->email];
}
Middleware
Middleware may inspect, modify, or short-circuit a request before the controller action runs.
use Shift\Middleware\MiddlewareInterface;
use Shift\Request;
use Shift\Response\JsonResponse;
use Shift\Response\Response;
final class AuthMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
if ($request->getHeader('Authorization') === null) {
return JsonResponse::error('Unauthorized', 401);
}
return $next($request);
}
}
Register middleware on the app:
$app->middleware(AuthMiddleware::class);
Built-in middleware includes CORS, authentication, and authorization middleware.
Modules
Modules are the main application boundary. A module can own controllers, routes, services, commands, config, models, middleware, and DTOs.
application/modules/Billing/
|-- Module.php
|-- Commands/
|-- Controllers/
|-- Services/
|-- Models/
|-- Middleware/
`-- Dto/
Create a module with the CLI:
./shift create:module Billing
A module boundary usually extends Shift\Modules\AbstractModule.
namespace Modules\Billing;
use Shift\Modules\AbstractModule;
use Shift\Routing\AttributeRouteLoader;
use Shift\Routing\Router\Router;
use Shift\Service\ServiceContainer;
use Modules\Billing\Controllers\InvoiceController;
use Modules\Billing\Services\InvoiceService;
final class Module extends AbstractModule
{
public function getName(): string
{
return 'billing';
}
public function registerServices(ServiceContainer $container): void
{
$container->singleton(InvoiceService::class, InvoiceService::class);
}
public function registerRoutes(Router $router): void
{
(new AttributeRouteLoader())->load($router, [
InvoiceController::class,
]);
}
}
Service Container
The service container stores regular services and singletons. It can resolve closures, class names, objects, and typed constructor dependencies.
$container->register(UserRepository::class, UserRepository::class);
$container->singleton(UserService::class, UserService::class);
$service = $container->resolve(UserService::class);
$controller = $container->make(UserController::class);
Console Commands
Commands implement Shift\Console\CommandInterface and may declare metadata with #[Command].
use Shift\Console\Attributes\Command;
use Shift\Console\Cli;
use Shift\Console\CommandInterface;
#[Command('billing:sync', aliases: ['sync-billing'], group: 'modules')]
final class SyncBilling implements CommandInterface
{
public function execute(mixed ...$args): void
{
(new Cli())->success('Billing synced.');
}
public function getHelp(): string
{
return 'Usage: ./shift billing:sync';
}
public function getDescription(): string
{
return 'Sync billing data.';
}
}
The help command groups commands by metadata and resolves aliases.
./shift help
./shift help billing:sync
./shift sync-billing
OpenAPI
The OpenAPI command generates an OpenAPI 3.0 JSON document from the routes registered in loaded modules.
./shift openapi
./shift openapi --output=docs/openapi.json
./shift openapi --validate
./shift openapi --live
The generator reads route paths, HTTP methods, controller handlers, path parameters, query parameters, request body attributes, request DTO rules, response status attributes, and response header attributes.
use Shift\OpenApi\Attributes\Description;
use Shift\OpenApi\Attributes\Response;
use Shift\OpenApi\Attributes\Schema;
use Shift\OpenApi\Attributes\Security;
use Shift\OpenApi\Attributes\Summary;
use Shift\OpenApi\Attributes\Tag;
#[Tag('Users')]
final class UserController
{
#[Summary('Create a user')]
#[Description('Creates a user account from a JSON request body.')]
#[Security('bearerAuth')]
#[Response(201, 'User created')]
public function store(
#[Schema(format: 'email')]
string $email
): array {
return ['email' => $email];
}
}
Available OpenAPI attributes include Summary, Description, Tag, Response, Deprecated, Security, and Schema. Validation mode checks the generated document for required OpenAPI fields, unique operation ids, responses, and declared path parameters.
./shift help openapi
./shift api:docs --output=storage/openapi.json
./shift openapi --live --host=127.0.0.1 --port=8088
Live mode writes the generated JSON into a temporary directory and starts a local documentation server with a lightweight Swagger-like HTML viewer, endpoint search, tag filtering, dark mode, and JSON download.
Database
ShiftPHP uses native PDO and registers Shift\Database\Database and the db alias lazily in the service container.
use Shift\Database\Database;
final class UserService
{
public function __construct(private readonly Database $db)
{
}
public function find(int $id): ?array
{
return $this->db
->query('select * from users where id = :id', ['id' => $id])
->first();
}
}
The core helpers are query(), execute(), table(), pdo(), transaction(), and lastInsertId().
Query Builder
The table query builder supports simple fluent selects, inserts, updates, deletes, ordering, limits, and offsets.
$users = $db->table('users')
->select('id', 'email')
->where('active', true)
->orderBy('id', 'desc')
->limit(10)
->get();
Models
Models extend Shift\Database\Model. Public properties represent columns. Attributes define primary keys, guarded fields, and casts.
use Shift\Database\Attributes\Cast;
use Shift\Database\Attributes\Guarded;
use Shift\Database\Attributes\PrimaryKey;
use Shift\Database\Model;
final class User extends Model
{
protected string $table = 'users';
#[PrimaryKey]
#[Cast('int')]
public ?int $id = null;
public string $email = '';
#[Guarded]
public string $role = 'user';
#[Cast('array')]
public array $meta = [];
}
$user = User::find(1, $db);
$user = User::create(['email' => 'dev@example.com'], $db);
$user->role = 'admin';
$user->save($db);
Migrations
Create migration files in database/migrations:
./shift create:migration create_users_table
A migration returns an anonymous class extending Shift\Database\Migration.
use Shift\Database\Database;
use Shift\Database\Migration;
return new class extends Migration
{
public function up(Database $db): void
{
$db->execute('create table users (id integer primary key autoincrement, email text not null)');
}
public function down(Database $db): void
{
$db->execute('drop table users');
}
};
./shift migrate
./shift migrate:status
./shift migrate:rollback
Logging
Structured exception logging is available through Shift\Logging\LoggerInterface. Logging is disabled by default and can be enabled with environment variables.
LOG_ENABLED=true
LOG_PATH=storage/logs/shift.log
The file logger writes JSON lines with timestamp, level, message, and context. Exception context includes class, status, code, file, line, and request metadata.
use Shift\Logging\LoggerInterface;
$app->getContainer()->singleton(LoggerInterface::class, new CustomLogger());
Cache
Module discovery can be cached for production. The generated cache file lives at storage/cache/modules.php.
./shift cache:modules
./shift cache:status
./shift cache:clear
Rebuild the module cache after changing module boundaries, module config, or module command mappings.
Quality Checks
The CLI includes zero-dependency quality gates for local development and pull request preparation.
./shift lint
./shift qa
shift lint checks PHP syntax and basic file hygiene, including trailing whitespace and missing final newlines. shift qa runs Composer validation, lint checks, the test suite, route listing, and OpenAPI generation.
./shift help lint
./shift help qa
Doctor
The doctor command runs local diagnostics and returns a non-zero exit code when a required check fails.
./shift doctor
It checks PHP version, required extensions, Composer JSON validity, PHP syntax, the test suite, environment presence, database config, and module cache status.
Testing
The project has a lightweight test runner in tests/ApiCoreTest.php. Shared helpers live in tests/Support, fixtures in tests/Fixtures, and feature tests in tests/Feature.
composer test
./shift test
The GitHub workflow validates Composer config, dumps autoload files, lints PHP files, runs tests, and verifies the route list command. Locally, ./shift qa runs the same kind of pre-PR quality gate and also checks OpenAPI generation.
Releases
Each pull request must have exactly one version label, such as v0.20.0. After a versioned PR is merged into master, GitHub Actions creates the matching tag and GitHub Release.
release/v0.20.0
label: v0.20.0
merge into master
automatic tag and release