LazyMePHP is a PHP 8+ rapid-development framework built around a single idea: the database schema is the application. Point it at a database and you get a full CRUD web UI, a GraphQL API, JWT-authenticated REST endpoints, and a developer dashboard — with zero code generation.
- MySQL, SQLite, and MSSQL support
- Runtime ORM — no generated model files
- Generic CRUD web UI driven by the live schema
- GraphQL API auto-built from the schema (
POST /graphql) - JWT authentication with refresh tokens for SPA / API consumers
- Role-based access control (RBAC)
- Database migration system
- Seeder and factory system for test data
- Audit log for all data mutations
- Batman developer dashboard with secure login
- Schema file cache for OPcache-friendly production deployments
- OpenAPI 3.0 spec auto-generated from live schema (
GET /openapi.json) - Health check endpoint (
GET /health) - Request ID tracing on every response (
X-Request-ID) - Pluggable cache layer: Redis, APCu, or in-process array
- General-purpose rate limiting middleware
- Background queue system: sync, database, or Redis drivers
- Standalone
FormRequestvalidation (controller-level, no model required) - File storage abstraction with local disk driver
- Multi-tenancy support (subdomain, header, path, or JWT resolution)
Only
public/should be web-accessible.
git clone https://github.com/Peixinho/LazyMePHP myProject
cd myProject && rm -rf .git
composer install
cp .env.example .env # edit DB_* and APP_* values
php LazyMePHP migrate # create framework tables
php LazyMePHP serveNavigate to http://localhost:8080. Every table in the database is immediately accessible at /{table} with list, create, edit, and delete pages, and via the GraphQL endpoint at POST /graphql.
All settings live in .env:
| Variable | Description |
|---|---|
DB_TYPE |
mysql, mssql, or sqlite |
DB_HOST |
Database host (MySQL / MSSQL) |
DB_NAME |
Database name (MySQL / MSSQL) |
DB_USER |
Database username (MySQL / MSSQL) |
DB_PASSWORD |
Database password (MySQL / MSSQL) |
DB_FILE_PATH |
Path to SQLite file |
APP_NAME |
Application name |
APP_TITLE |
HTML page title |
APP_TIMEZONE |
PHP timezone string (e.g. Europe/Lisbon) |
APP_NRESULTS |
Default page size for paginated lists |
APP_ENCRYPTION |
Secret key (≥ 32 chars) — used for JWT signing |
APP_ENV |
development enables GraphQL introspection and debug traces |
APP_CORS_ORIGIN |
Exact origin allowed for cross-origin requests (empty = block all) |
APP_ACTIVITY_LOG |
true to enable change audit logging |
APP_ACTIVITY_AUTH |
Fallback identifier written to the audit log when no JWT user is present |
AUTH_TABLE |
Table used for JWT login (enables POST /auth/login) |
AUTH_USERNAME_COLUMN |
Column checked as the login credential |
AUTH_PASSWORD_COLUMN |
Column holding the bcrypt-hashed password |
AUTH_TOKEN_TTL |
JWT lifetime in seconds (default 3600) |
AUTH_REFRESH_TTL |
Refresh token lifetime in seconds (default 2592000 = 30 days) |
BATMAN_USERNAME |
Batman dashboard login username (default admin) |
BATMAN_SECRET |
Batman dashboard password as a bcrypt hash |
OPENAPI_ENABLED |
Set to false to disable the /openapi.json endpoint |
CACHE_DRIVER |
Cache backend: array (default), apcu, redis |
REDIS_HOST |
Redis host (default 127.0.0.1) |
REDIS_PORT |
Redis port (default 6379) |
REDIS_PASSWORD |
Redis password (empty = no auth) |
REDIS_DB |
Redis database index (default 0) |
QUEUE_DRIVER |
Queue backend: sync (default), database, redis |
STORAGE_DRIVER |
Storage driver: local (default) |
STORAGE_PATH |
Root directory for local storage (default storage/app) |
STORAGE_URL |
Public URL prefix for stored files (default /storage) |
TENANT_TABLE |
Tenants table name (enables multi-tenancy when set) |
TENANT_COLUMN |
Column used to look up the tenant (default slug) |
TENANT_RESOLVE |
How to identify the tenant: subdomain | header | path | jwt |
TENANT_REQUIRE |
Return 400/404 if no tenant found (default true) |
On every request LazyMePHP::boot($blade) (called from App/Routes/Routes.php):
- Reads the list of tables from the schema cache, or queries the DB directly.
- Emits a
X-Request-IDheader for tracing. - Registers 6 CRUD web routes per table via
Core\AutoRouter. - Registers
POST /graphqlviaCore\GraphQL\Endpoint. - Registers
POST /auth/login,POST /auth/logout,POST /auth/refresh,GET /auth/mewhenAUTH_TABLEis set. - Registers
GET /health(health check) andGET /openapi.json(OpenAPI spec).
No files are generated. Schema introspection happens once per table per process (cached in memory), and optionally pre-warmed to disk for production.
Model introspects the DB schema at runtime and provides full CRUD.
use Core\Model;
// Create
$user = new Model('users');
$user->name = 'Alice';
$user->email = 'alice@example.com';
$user->Save();
// Load by primary key
$user = new Model('users', 1);
echo $user->name; // Alice
// Update
$user->name = 'Alice Smith';
$user->Save();
// Delete
$user->Delete();$active = Model::query('users')
->where('active', 1)
->where('age', 18, '>=')
->orderBy('name')
->limit(20)
->get(); // returns Model[]
$count = Model::query('users')->where('active', 1)->count();
$row = Model::query('users')->where('email', $email)->first();Other where variants:
->whereLike('name', '%alice%')
->whereNull('deleted_at')
->whereNotNull('verified_at')
->whereIn('status', ['active', 'trial'])
->whereRaw('"score" > ? OR "admin" = 1', [50]) // raw SQL, AND by default
->whereRaw('"role" = ?', ['editor'], 'OR') // change connector$rows = Model::query('orders')
->join('customers', 'orders.customer_id', 'customers.id')
->leftJoin('coupons', 'orders.coupon_id', 'coupons.id')
->select('orders.*', 'customers.name AS customer_name', 'coupons.code AS coupon')
->where('orders.status', 'open')
->orderBy('orders.created_at', 'DESC')
->get();
// Columns from joined tables and aliases come through as model properties:
echo $rows[0]->customer_name;
echo $rows[0]->coupon; // null when left-join partner is missingAvailable join methods: join() (INNER), leftJoin(), rightJoin().
// Restrict columns
Model::query('users')->select('id', 'name', 'email')->get();
// Aggregate expressions
$rows = Model::query('orders')
->select('customer_id', 'SUM(total) AS revenue', 'COUNT(*) AS cnt')
->groupBy('customer_id')
->having('revenue', 1000, '>=')
->orderBy('revenue', 'DESC')
->get();
echo $rows[0]->revenue;
echo $rows[0]->cnt;having(column, value, operator) defaults to =. Runs after GROUP BY.
For SQL that ModelQuery cannot express — CTEs, UNION, window functions, subqueries in FROM:
$result = LazyMePHP::DB_CONNECTION()->query('
WITH ranked AS (
SELECT *, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
FROM "employees"
)
SELECT * FROM ranked WHERE rnk = 1
', []);
$rows = [];
while ($row = $result->fetchArray()) $rows[] = $row;
$models = Model::hydrate('employees', $rows);
// schema columns + computed aliases (rnk) all accessible as properties
echo $models[0]->name;
echo $models[0]->rnk;$result = Model::query('users')
->where('active', 1)
->paginate(perPage: 15, page: 2);
// $result = [
// 'data' => Model[],
// 'total' => 120,
// 'per_page' => 15,
// 'current_page' => 2,
// 'last_page' => 8,
// 'from' => 16,
// 'to' => 30,
// ]// Bulk update every row matching the query
Model::query('users')
->where('trial', 1)
->update(['active' => 0, 'trial' => 0]);
// Bulk delete matching rows
Model::query('users')->where('deleted_at', null, '!=')->bulkDelete();
// Bulk insert (returns number of rows inserted)
Model::insertMany('tags', [
['name' => 'php'],
['name' => 'framework'],
]);use Core\Model;
Model::transaction(function () {
$order = new Model('orders');
$order->user_id = 1;
$order->Save();
$item = new Model('order_items');
$item->order_id = $order->getPrimaryKey();
$item->product_id = 42;
$item->Save();
});
// Automatically rolled back on exception.namespace Models;
use Core\Model;
class User extends Model {
protected static string $table = 'users';
}
$user = new User(1);
$users = User::query()->where('active', 1)->get();class Post extends Model {
protected static string $table = 'posts';
public function author(): ?Model {
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): array {
return $this->hasMany(Comment::class, 'post_id');
}
}
// Eager loading (prevents N+1)
$posts = Post::query()->with('author', 'comments')->get();Supported: belongsTo, hasMany, hasOne, belongsToMany.
Add deleted_at DATETIME NULL to a table, then use the trait:
use Core\Model;
use Core\SoftDeletes;
class Post extends Model {
use SoftDeletes;
protected static string $table = 'posts';
}
$post->Delete(); // sets deleted_at, row stays in DB
$post->restore(); // clears deleted_at
$post->isTrashed(); // true if deleted_at is set
// Queries automatically exclude soft-deleted rows:
Post::query()->get(); // only non-deleted
Post::query()->withTrashed()->get(); // include deleted
Post::query()->onlyTrashed()->get(); // only deletedclass User extends Model {
protected static string $table = 'users';
protected static array $rules = [
'name' => 'required|min:2|max:100',
'email' => 'required|email',
'age' => 'integer|min:0',
'role' => 'in:admin,editor,viewer',
'site' => 'url',
];
}
$user->name = '';
$user->email = 'not-an-email';
if (!$user->passes()) {
print_r($user->errors());
// ['name' => ['The name field is required.'], 'email' => ['...must be a valid email']]
}
// Or get all errors at once:
$errors = $user->validate();Available rules: required, email, integer, numeric, min:N, max:N, in:a,b,c, url, boolean.
use Core\Events\ModelEvents;
// Listen for any save on 'orders'
ModelEvents::listen('orders', 'created', function (Model $order) {
// send confirmation email
});
// Cancel a delete by returning false
ModelEvents::listen('orders', 'deleting', function (Model $order) {
if ($order->status === 'completed') return false;
});
// Observer class
class OrderObserver {
public function creating(Model $m): void { /* set defaults */ }
public function updated(Model $m): void { /* clear cache */ }
}
ModelEvents::registerObserver('orders', new OrderObserver());
// or on the model class:
Order::observe('orders', new OrderObserver());Events fired: creating, created, updating, updated, deleting, deleted, saving, saved.
Returning false from creating, updating, or deleting cancels the operation.
Apply automatic query constraints to every query on a model:
class ActiveUser extends Model {
protected static string $table = 'users';
protected static array $globalScopes = [];
}
// Register once (e.g. in a service provider or boot):
ActiveUser::addGlobalScope('active', fn($q) => $q->where('active', 1));
ActiveUser::query()->get(); // WHERE active = 1 always applied
ActiveUser::withoutGlobalScopes()->get(); // bypass all scopes
ActiveUser::removeGlobalScope('active'); // remove permanentlyDefine reusable query constraints on the model class:
class Product extends Model {
protected static string $table = 'products';
public function scopeActive(\Core\ModelQuery $q): void {
$q->where('active', 1);
}
public function scopePricedBelow(\Core\ModelQuery $q, float $max): void {
$q->where('price', $max, '<');
}
}
Product::query()->active()->pricedBelow(50)->get();
// or: Product::query()->scope('active')->scope('pricedBelow', 50)->get();// Cache for 60 seconds (uses APCu when available, in-process array otherwise)
$users = Model::query('users')
->where('active', 1)
->remember(60)
->get();
// With explicit cache key
$users = Model::query('users')
->remember(300, 'active-users-list')
->get();
// Clear in-process cache (useful in tests)
Model::query('users')->clearMemCache();Add these to .env to enable auth endpoints:
AUTH_TABLE=users
AUTH_USERNAME_COLUMN=email
AUTH_PASSWORD_COLUMN=password
AUTH_TOKEN_TTL=3600
AUTH_REFRESH_TTL=2592000Hash a password for storage:
php LazyMePHP auth:hash mypassword| Method | Path | Description |
|---|---|---|
POST |
/auth/login |
Returns {access_token, token_type, expires_in, refresh_token, refresh_expires_in} |
POST |
/auth/refresh |
Rotates the refresh token and issues a new access token |
POST |
/auth/logout |
Revokes the provided refresh token |
GET |
/auth/me |
Returns the authenticated user (requires Bearer token) |
Refresh tokens are opaque 64-character hex strings. The raw token is returned to the client once; only its SHA-256 hash is stored in the database. On every /auth/refresh call the old token is immediately revoked and a new pair is issued (rotation).
POST /auth/login
→ {
"access_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<64-hex>",
"refresh_expires_in": 2592000
}
POST /auth/refresh { "refresh_token": "<64-hex>" }
→ { "access_token": "<new-jwt>", "refresh_token": "<new-64-hex>", ... }The refresh endpoint is rate-limited to 20 requests per 5 minutes per IP.
use Core\Auth\JwtMiddleware;
$router->post('/orders', [OrderController::class, 'store'])
->addMiddleware(JwtMiddleware::class);use Core\Auth\Auth;
// In a protected context (Bearer token already validated by middleware)
$user = Auth::user(); // array without password column
$id = Auth::id();
$ok = Auth::check(); // true when a valid Bearer token is presentRun php LazyMePHP migrate to create the RBAC tables (__AUTH_ROLES, __AUTH_ROLE_PERMISSIONS, __AUTH_USER_ROLES).
use Core\Auth\RBAC;
// Setup
RBAC::createRole('admin');
RBAC::createRole('editor');
RBAC::grantPermission('editor', 'posts.create');
RBAC::grantPermission('editor', 'posts.update');
RBAC::assignRole($userId, 'editor');
// Checks
RBAC::can($userId, 'posts.create'); // true
RBAC::is($userId, 'editor'); // true
RBAC::is($userId, 'admin'); // false
RBAC::rolesFor($userId); // ['editor']
RBAC::permissionsFor($userId); // ['posts.create', 'posts.update']use Core\Auth\RequiresPermission;
use Core\Auth\RequiresRole;
// Require a specific permission
$router->post('/posts', [PostController::class, 'store'])
->addMiddleware(new RequiresPermission('posts.create'));
// Require a role (any of the listed roles)
$router->get('/admin', [AdminController::class, 'index'])
->addMiddleware(new RequiresRole('admin', 'superuser'));
// Require ALL listed roles
$router->delete('/nuke', [AdminController::class, 'nuke'])
->addMiddleware((new RequiresRole('admin', 'superuser'))->all());Both middleware return 401 if the request is unauthenticated, 403 if the role/permission check fails.
Shape model output for APIs:
use Core\Http\ApiResource;
class UserResource extends ApiResource {
public function toArray(): array {
return [
'id' => $this->model->id,
'name' => $this->model->name,
'email' => $this->model->email,
// password is omitted
];
}
}
// Single resource
UserResource::make($user)->respond(); // sets header + outputs JSON
$json = UserResource::make($user)->toJson();
// Collection
UserResource::collection($users)->respond();
// With metadata
UserResource::collection($users)
->withMeta(['total' => 120, 'page' => 2])
->respond();Response shape:
{ "data": { "id": 1, "name": "Alice", "email": "alice@example.com" } }
{ "data": [...], "meta": { "total": 120, "page": 2 } }Seeders populate the database with initial or test data.
php LazyMePHP make:seeder UserSeeder # scaffold App/Seeders/UserSeeder.php
php LazyMePHP db:seed # run all seeders
php LazyMePHP db:seed --class=UserSeeder// App/Seeders/UserSeeder.php
use Core\Seeder\Seeder;
class UserSeeder extends Seeder {
public function run(): void {
$this->insert('users', ['name' => 'Admin', 'email' => 'admin@example.com']);
}
}Factories generate model instances for tests and seeding.
php LazyMePHP make:factory PostFactory # scaffold App/Factories/PostFactory.php// App/Factories/PostFactory.php
use Core\Factory\Factory;
class PostFactory extends Factory {
protected string $table = 'posts';
public function definition(): array {
static $n = 0; $n++;
return [
'title' => "Post {$n}",
'body' => 'Lorem ipsum',
'user_id' => 1,
];
}
}
// Usage
$post = PostFactory::new()->make(); // unsaved Model
$post = PostFactory::new()->create(); // saved to DB
$posts = PostFactory::new()->count(10)->create(); // 10 saved models
$post = PostFactory::new()->state(['user_id' => 5])->create();Migrations live in database/migrations/ as plain PHP files:
// database/migrations/2026_07_14_0001_create_posts.php
return [
'up' => function ($db): void {
$db->query("CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT,
user_id INTEGER NOT NULL
)");
},
'down' => function ($db): void {
$db->query("DROP TABLE IF EXISTS posts");
},
];Scaffold a new file:
php LazyMePHP make:migration create_postsRun and manage migrations:
php LazyMePHP migrate # run all pending migrations
php LazyMePHP migrate:rollback # roll back the last batch
php LazyMePHP migrate:rollback --step=3
php LazyMePHP migrate:reset # roll back all migrations
php LazyMePHP migrate:status # show which migrations have runMigration state is tracked in __migrations. The schema cache is cleared automatically after every run or rollback.
Every table gets these routes automatically:
| Method | Path | Action |
|---|---|---|
| GET | /{table} |
List with pagination and filters |
| GET | /{table}/new |
New record form |
| GET | /{table}/{id}/edit |
Edit form |
| POST | /{table} |
Create |
| POST | /{table}/{id} |
Update |
| POST | /{table}/{id}/delete |
Delete |
These 6 routes are fixed — to add, drop, or reshape routes for a table entirely (not just what happens inside them), create App/Routes/{table}.php or scaffold one with php LazyMePHP make:router <table>. Its presence fully replaces the standard 6 for that table.
Views are rendered by generic Blade templates in App/Views/_Crud/. To override a table's view, create App/Views/{TableName}/index.blade.php or edit.blade.php — the controller resolves to the table-specific file first, then falls back to the generic template.
Create App/Controllers/{TableName}.php to override behaviour for a specific table — or scaffold it with php LazyMePHP make:controller <table> (add --hidden to exclude the table from auto-routing and GraphQL). The GraphQL API needs no separate scaffolding: it's generated at runtime from this same controller.
namespace Controllers;
use Core\CrudController;
use Core\Model;
class Users extends CrudController {
protected static string $table = 'users';
protected function foreignKeys(): array {
return ['role_id' => 'roles'];
}
protected function extraValidationRules(): array {
return [
'username' => ['validations' => [\Core\Validations\ValidationsMethod::STRING], 'required' => true],
];
}
protected function beforeSave(Model $obj, array &$data, bool $isUpdate): void {
$data['updated_at'] = date('Y-m-d H:i:s');
}
protected function afterSave(Model $obj, bool $isUpdate): void {}
protected function beforeDelete(Model $obj): void {}
public function exposedFields(): array {
return ['id', 'name', 'email', 'role_id', 'created_at'];
}
// Restricts this table's GraphQL queries/mutations AND its web CRUD
// routes to callers with the given role(s) — one declaration, checked by
// Core\Auth\Gate for both surfaces. Empty (the default) means no
// restriction beyond authentication. Applies to both reading and
// writing; override requiredRolesForRead()/requiredRolesForWrite()
// instead when a table needs those to differ (e.g. anyone can browse,
// only 'admin' can create/update/delete).
public function requiredRoles(): array {
return ['admin'];
}
// Restrict access to a *specific* record (e.g. "edit your own, not anyone
// else's") — requiredRoles() can't express this, it never sees which
// record is targeted. Checked for the single-record query/edit page,
// update, and delete; not called for the list query/page or create.
public function authorizeRecord(string $operation, Model $record): bool {
return (string) \Core\Auth\Auth::id() === (string) $record->getPrimaryKey();
}
// public static bool $hidden = true; // exclude from auto-wiring
}POST /graphql
Content-Type: application/json
{ usersList(page: 1, limit: 20) { id name email } }
{ users(id: 1) { id name email } }mutation { createUsers(input: { name: "Alice", email: "alice@example.com" }) { id } }
mutation { updateUsers(id: 1, input: { name: "Alice Smith" }) { id name } }
mutation { deleteUsers(id: 1) }| Measure | Value |
|---|---|
| Query depth limit | 7 |
| Query complexity limit | 200 |
| Introspection | Disabled outside APP_ENV=development |
| Stack traces | Stripped outside APP_ENV=development |
| Authentication | JwtMiddleware rejects requests with no valid Bearer token when AUTH_TABLE is configured |
| Authorization | Per-table, via CrudController::requiredRoles() — enforced identically for GraphQL and the web CRUD routes, see below |
A JWT Bearer token is all JwtMiddleware can check — it runs before the query is even parsed, and one GraphQL request can touch several tables at once, so there's no single route to attach a per-table role check to (the same is true of Core\AutoRouter, which registers all 6 web CRUD routes generically for every table). Per-table restriction is opt-in on the table's controller instead, and governs both surfaces at once:
class Users extends CrudController {
public function requiredRoles(): array {
return ['admin']; // empty (default) = no restriction beyond authentication
}
}Every query/mutation for that table — and every web CRUD route — then goes through Core\Auth\Gate::checkRoles(), which checks Core\Auth\RBAC::is($role) and throws (GraphQL\Error\UserError for GraphQL, a 401/403 response for the web routes) if the caller has none of the required roles. RBAC::is() resolves the current user via RBAC::$identityResolver first (wire this to your app's own session/login mechanism if it's not JWT), then falls back to Core\Auth\Auth::id() (JWT) — see Security.
requiredRoles() applies the same list to both reading and writing. Override requiredRolesForRead() / requiredRolesForWrite() instead when a table needs those to differ — e.g. any authenticated user can browse it, only a manager role can change it:
class Rooms extends CrudController {
public function requiredRolesForRead(): array { return []; } // anyone authenticated
public function requiredRolesForWrite(): array { return ['admin']; } // create/update/delete only
}Both default to requiredRoles(), so overriding neither keeps the single-list behavior above.
For row-level rules ("edit your own record, not anyone else's") — which table-level role lists can't express at all, since they never see which record is targeted — override authorizeRecord() instead. It runs after the table-level check, with the actual target record already loaded, for the single-record query/edit page, update, and delete (not the list query/page or create):
class Users extends CrudController {
public function requiredRolesForWrite(): array {
return []; // any authenticated user may attempt a write — narrowed below
}
public function authorizeRecord(string $operation, Model $record): bool {
if (RBAC::is('admin')) return true; // admins may touch anyone
return (string) Auth::id() === (string) $record->getPrimaryKey();
}
}There's no per-table way to replace the GraphQL resolvers or the web CRUD routes themselves — requiredRoles*() and authorizeRecord() are the extension points, checked by Core\Auth\Gate for both, and mutations/form saves still call your controller's beforeSave()/afterSave() either way, so business logic has its usual home.
A full OpenAPI 3.0 specification is auto-generated from the live schema:
GET /openapi.json
The spec includes CRUD paths for every non-system table (tables without __ prefix), plus auth endpoints when AUTH_TABLE is configured. Disable with OPENAPI_ENABLED=false in .env.
GET /health
Returns 200 OK when the database is reachable, 503 Service Unavailable otherwise:
{
"status": "ok",
"db": { "status": "ok", "type": "sqlite" },
"php": "8.3.0",
"memory": { "used": "4.2 MB", "peak": "5.1 MB", "limit": "128M" }
}Every response includes an X-Request-ID header. If the incoming request already has a valid X-Request-ID (alphanumeric + hyphens, max 36 chars), it is echoed back; otherwise a new UUID-shaped value is generated.
use Core\Http\RequestId;
$id = RequestId::current(); // access the current request's ID anywhereSet APP_ACTIVITY_LOG=true. The following tables are created automatically:
| Table | Contents |
|---|---|
__LOG_ACTIVITY |
One row per mutating request (INSERT / UPDATE / DELETE) |
__LOG_DATA |
Per-field before/after values for every change |
__LOG_ERRORS |
Application errors with severity and context |
__LOG_PERFORMANCE |
Slow-operation metrics when monitoring is enabled |
Only mutating requests are logged — plain reads produce no audit entry.
Sensitive column names (password, token, secret, api_key, api_secret, and the value of AUTH_PASSWORD_COLUMN) are automatically stripped from __LOG_DATA. The authenticated JWT user id is written to __LOG_ACTIVITY.user when available.
Batman is an internal developer dashboard available at /batman/. It shows activity logs, error logs, performance metrics, and per-record change history with before/after diffs.
Generate a bcrypt password hash:
php -r "echo password_hash('yourpassword', PASSWORD_BCRYPT), PHP_EOL;"Add to .env:
BATMAN_USERNAME=admin
BATMAN_SECRET=$2y$12$... # paste the hash aboveBatman authenticates against BATMAN_SECRET using password_verify() — it does not use database credentials.
Pre-warm in production so no DB introspection happens at request time:
php LazyMePHP schema:cache # cache all tables
php LazyMePHP schema:cache users # cache one table
php LazyMePHP schema:clear # remove all cache filesCache files are written to App/Cache/schema/{table}.php as plain PHP arrays — OPcache serves them as compiled bytecode.
Configure via .env:
CACHE_DRIVER=redis # array (default) | apcu | redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0use Core\Cache\Cache;
Cache::set('key', $value, 3600);
$value = Cache::get('key');
Cache::delete('key');
Cache::has('key');
Cache::increment('rate:hits', 1, 60);
// Get or compute:
$users = Cache::remember('all-users', 300, fn() => Model::query('users')->get());The same driver backs ModelQuery::remember() — switching CACHE_DRIVER=redis makes all query caching persistent across workers automatically.
use Core\Http\RateLimit;
// 60 requests per minute per IP (default key)
$router->post('/api/contact', [ContactController::class, 'send'])
->addMiddleware(new RateLimit(60, 60));
// 5 requests per minute per authenticated user
$router->post('/api/ai', [AiController::class, 'complete'])
->addMiddleware(new RateLimit(5, 60, fn() => Auth::id() ?? 'anon'));Exceeded requests get 429 Too Many Requests with Retry-After and X-RateLimit-* headers.
Configure the driver:
QUEUE_DRIVER=database # sync (default) | database | redisDefine a job:
php LazyMePHP make:job SendWelcomeEmail// App/Jobs/SendWelcomeEmail.php
class SendWelcomeEmail extends \Core\Queue\Job {
public int $tries = 3;
public int $userId = 0;
public function handle(): void {
$user = new Model('users', $this->userId);
mail($user->email, 'Welcome!', '...');
}
public function failed(\Throwable $e): void {
// log or alert on permanent failure
}
}Dispatch from anywhere:
use Core\Queue\Queue;
Queue::dispatch(new SendWelcomeEmail(['userId' => $user->getPrimaryKey()]));
Queue::dispatch(new SendWelcomeEmail(['userId' => 1]), 'high'); // named queueRun the worker:
php LazyMePHP queue:work # default queue
php LazyMePHP queue:work --queue=high --sleep=1
php LazyMePHP queue:work --stop-when-empty # exit after draining
php LazyMePHP queue:size # pending job countThe database driver stores jobs in __queue_jobs (created by php LazyMePHP migrate). The redis driver uses Redis lists.
Validate controller input independently of any model:
php LazyMePHP make:request CreatePostRequest// App/Requests/CreatePostRequest.php
class CreatePostRequest extends \Core\Http\FormRequest {
public function rules(): array {
return [
'title' => 'required|min:3|max:255',
'body' => 'required',
'email' => 'required|email',
'role' => 'in:admin,editor',
'site' => 'url',
'age' => 'integer|min:0|max:120',
];
}
public function authorize(): bool {
return Auth::check();
}
}
// In a controller or route handler:
$req = new CreatePostRequest(); // reads from $_POST / JSON body automatically
if ($req->fails()) {
return json_encode(['errors' => $req->errors()]);
}
$data = $req->validated(); // only rule-listed fieldsAvailable rules: required, email, url, integer, numeric, boolean, min:N, max:N, in:a,b,c, regex:/pattern/.
STORAGE_DRIVER=local
STORAGE_PATH=storage/app
STORAGE_URL=/storageuse Core\Storage\Storage;
use Core\Storage\UploadedFile;
// Store arbitrary content
Storage::disk()->put('reports/2026-07.csv', $csv);
$contents = Storage::disk()->get('reports/2026-07.csv');
$url = Storage::disk()->url('reports/2026-07.csv'); // /storage/reports/2026-07.csv
// Handle a file upload
$file = UploadedFile::fromInput('avatar');
if ($file && $file->isValid()) {
$path = $file->store('avatars'); // 'avatars/<random-hex>.jpg'
$url = Storage::disk()->url($path);
}
// Multiple uploads
$files = UploadedFile::fromInputMultiple('documents');
foreach ($files as $f) {
if ($f->isValid()) $f->store('docs');
}Add a tenants table, then configure:
TENANT_TABLE=tenants
TENANT_COLUMN=slug
TENANT_RESOLVE=subdomain # subdomain | header | path | jwt
TENANT_REQUIRE=trueWire up the middleware on all tenant routes:
use Core\Tenancy\TenantMiddleware;
$router->group(['middleware' => TenantMiddleware::class], function () {
// All routes here resolve and require a valid tenant
});Access the current tenant anywhere:
use Core\Tenancy\Tenant;
Tenant::id(); // e.g. 1
Tenant::slug(); // e.g. 'acme'
Tenant::get('name'); // any column from the tenants table
Tenant::isResolved(); // true after middleware ranScope models to the current tenant automatically using HasTenant:
use Core\Model;
use Core\Tenancy\HasTenant;
class Post extends Model {
use HasTenant;
protected static string $table = 'posts';
// posts must have a tenant_id column
}
// In boot() or a service provider:
Post::initializeTenantScope();
// Now every query is automatically scoped:
Post::query()->get(); // WHERE tenant_id = <current> always applied
// New records get tenant_id set automatically on Save()Resolution strategies:
- subdomain:
acme.app.example.com→ identifieracme - header:
X-Tenant-ID: acmerequest header - path:
/acme/posts→ identifieracme(first URL segment) - jwt:
tenantclaim in the Bearer JWT
php LazyMePHP serve Start the PHP development server (port 8080)
php LazyMePHP migrate Run all pending migrations
php LazyMePHP migrate:rollback Roll back the last migration batch
php LazyMePHP migrate:rollback --step=N
php LazyMePHP migrate:reset Roll back all migrations
php LazyMePHP migrate:status Show migration run history
php LazyMePHP make:migration <name> Scaffold a new migration file
php LazyMePHP make:model <Name> Scaffold a Model subclass
php LazyMePHP make:controller <table> Scaffold App/Controllers/{Table}.php extending CrudController
php LazyMePHP make:controller <table> --hidden ...and exclude it from auto-routing + GraphQL
php LazyMePHP make:view <table> Scaffold App/Views/{table}/index.blade.php + edit.blade.php
php LazyMePHP make:router <table> Scaffold App/Routes/{table}.php — fully replaces its 6 standard routes
php LazyMePHP make:all <table> Scaffold both the controller and the views for a table
php LazyMePHP make:seeder <Name> Scaffold a Seeder class in App/Seeders/
php LazyMePHP make:factory <Name> Scaffold a Factory class in App/Factories/
php LazyMePHP make:observer <Name> Scaffold a model observer class
php LazyMePHP make:resource <Name> Scaffold an ApiResource subclass
php LazyMePHP make:job <Name> Scaffold a queue Job class in App/Jobs/
php LazyMePHP make:request <Name> Scaffold a FormRequest in App/Requests/
php LazyMePHP make:mail <Name> Scaffold a Mailable class in App/Mail/
php LazyMePHP make:test <Name> Scaffold a Pest test
php LazyMePHP make:command <Name> Scaffold a console command
php LazyMePHP make:middleware <Name> Scaffold a middleware class
php LazyMePHP db:seed Run all seeders in App/Seeders/
php LazyMePHP db:seed --class=<Name> Run a specific seeder class
php LazyMePHP auth:hash <password> Print a bcrypt hash of <password>
php LazyMePHP schema:cache Pre-warm schema cache for all tables
php LazyMePHP schema:cache <table> Pre-warm schema cache for one table
php LazyMePHP schema:clear Remove all schema cache files
php LazyMePHP queue:work Start the queue worker (default queue)
php LazyMePHP queue:work --queue=<name> Work a specific named queue
php LazyMePHP queue:work --sleep=3 Seconds to sleep when queue is empty
php LazyMePHP queue:work --tries=3 Max attempts per job before failing
php LazyMePHP queue:work --stop-when-empty Exit after draining the queue
php LazyMePHP queue:size Show pending job count
php LazyMePHP queue:size --queue=<name> Show pending count for a named queue
| Area | Measure |
|---|---|
| Sessions | httponly, samesite=Strict, secure in production |
| CSRF | Token-per-session with rotation; all web form posts validated |
| CORS | Exact-origin allowlist via APP_CORS_ORIGIN; wildcard blocked |
| JWT | HS256, signed with APP_ENCRYPTION (≥ 32 chars enforced) |
| Refresh tokens | Opaque 64-char hex; SHA-256 hash stored in DB; rotation on every use |
| RBAC | Role + permission middleware; 401 when unauthenticated, 403 when unauthorised |
| Batman login | bcrypt password_verify() against BATMAN_SECRET |
| Redirects | Path-only redirects; host stripping prevents open redirect |
| SQL injection | All queries use prepared statement placeholders |
| Column injection | Filter and sort columns validated against live schema |
| CSP | default-src 'self'; no unsafe-inline |
| GraphQL & CRUD UI | Depth 7, complexity 200, introspection off in production; per-table role authorization via requiredRoles() — one declaration governs both surfaces |
| Audit log | Sensitive columns auto-stripped; passwords never logged |
| Rate limiting | Refresh token endpoint: 20 requests per 5 minutes per IP |
- PHP 8.1+
- Composer
- MySQL, MSSQL, or SQLite
MIT