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')]ClassPrefixes all controller routes.
#[Get('/path')]MethodRegisters a GET route.
#[Post('/path')]MethodRegisters a POST route.
#[Put('/path')]MethodRegisters a PUT route.
#[Patch('/path')]MethodRegisters a PATCH route.
#[Delete('/path')]MethodRegisters a DELETE route.
#[Status(201)]MethodOverrides the response status.
#[Header('X-Name', 'value')]MethodAdds 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.

Malformed JSON

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