v1.0.0 — PHP 8.5

PulseMVC Documentation

Complete reference for the PulseMVC framework — routing, models, views, CLI and more.

Introduction

PulseMVC is a lightweight, fast, and expressive PHP 8.5 MVC framework built from scratch — zero bloat, full control. It offers attribute-based routing, an Active Record ORM, a powerful CLI, Twig 3 templates, and a suite of modern JavaScript UI modules, all in a single dependency-light package.

Requirements: PHP 8.5+, Composer, MySQL 8+ (or MariaDB 10.6+), and a web server (Apache / Nginx / built-in PHP server).

Philosophy

  • Convention over configuration — sensible defaults, minimal setup.
  • Attribute-driven routing — PHP 8 attributes replace route files.
  • No magic — every entry point is traceable and readable.
  • Minimal dependencies — only Twig 3 and phpdotenv are required.

Installation

Via Composer

composer create-project pulsemvc/framework my-app
cd my-app

Manual Clone

git clone https://github.com/pulsemvc/framework.git my-app
cd my-app
composer install

Environment Setup

cp .env.example .env
php mvc key:generate

Edit .env with your database credentials:

APP_NAME=PulseMVC
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_KEY=          # generated above

DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pulsemvc
DB_USERNAME=root
DB_PASSWORD=secret

Database Setup

php mvc migrate
php mvc db:seed

Start Development Server

php mvc serve
# Listening on http://127.0.0.1:8000

Web Server (Apache)

Point your document root to the public/ directory. The included .htaccess handles URL rewriting:

<VirtualHost *:80>
    ServerName myapp.local
    DocumentRoot /var/www/my-app/public
    <Directory /var/www/my-app/public>
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

Web Server (Nginx)

server {
    listen 80;
    server_name myapp.local;
    root /var/www/my-app/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

Configuration

All configuration is stored in .env and accessed via the env() or config() helpers. There are no PHP config files to manage.

KeyDefaultDescription
APP_NAMEPulseMVCApplication display name
APP_ENVproductionlocal | staging | production
APP_DEBUGfalseShow detailed error pages
APP_URLhttp://localhostBase URL for asset/URL generation
APP_KEY32-byte base64 secret (required)
APP_VERSION1.0.0Application version string
APP_TIMEZONEUTCPHP default timezone
DB_HOST127.0.0.1MySQL host
DB_PORT3306MySQL port
DB_DATABASEDatabase name
DB_USERNAMErootDatabase user
DB_PASSWORDDatabase password
SESSION_LIFETIME7200Session TTL in seconds
CACHE_TTL3600Default cache TTL in seconds
LOG_LEVELdebugdebug | info | warning | error

Directory Structure

my-app/
├── app/
│   ├── Controllers/       # HTTP controllers
│   ├── Middleware/        # Custom middleware classes
│   ├── Models/            # Active Record models
│   ├── Services/          # Business logic services
│   └── Views/             # Twig templates
│       ├── layouts/       # Base layouts (base, app, guest)
│       ├── partials/      # Reusable partials
│       ├── auth/          # Login / register pages
│       ├── errors/        # 404, 403, 500, maintenance
│       └── home/          # Public pages
│
├── core/                  # Framework internals (do not edit)
│   ├── CLI/               # CLI commands & console
│   ├── Exceptions/        # HTTP exception classes
│   ├── Twig/              # Twig extensions
│   └── *.php              # Router, Model, Cache, etc.
│
├── database/
│   ├── migrations/        # Database migration files
│   └── seeds/             # Seeder classes
│
├── public/
│   ├── index.php          # Application entry point
│   ├── css/app.css        # Compiled CSS
│   └── js/                # JS modules
│
├── storage/
│   ├── cache/             # File cache store
│   ├── logs/              # Application logs
│   └── backups/           # Database backups
│
├── .env                   # Environment variables (not committed)
├── .env.example           # Template for .env
├── composer.json
└── mvc                    # CLI entry point

Routing

Routes are defined using PHP 8 attributes directly on controller methods. No route file is needed — the framework scans app/Controllers/ automatically.

Basic Route

#[Route('/hello', method: 'GET', name: 'hello')]
public function hello(Request $request): Response
{
    return $this->view('hello');
}

Route Parameters

// Required segment — matches any value
#[Route('/users/{id}', method: 'GET')]
public function show(Request $request, int $id): Response {}

// Constrained with regex
#[Route('/posts/{slug:[a-z0-9-]+}', method: 'GET')]
public function post(Request $request, string $slug): Response {}

// Optional segment
#[Route('/archive/{year?}', method: 'GET')]
public function archive(Request $request, ?int $year = null): Response {}

HTTP Methods

#[Route('/users', method: 'POST', name: 'users.store')]
#[Route('/users/{id}', method: 'PUT', name: 'users.update')]
#[Route('/users/{id}', method: 'DELETE', name: 'users.destroy')]

// Multiple methods
#[Route('/api/data', method: ['GET', 'HEAD'])]

Route Groups

#[RouteGroup(prefix: '/admin', namePrefix: 'admin.', middleware: ['auth', 'admin'])]
class AdminController extends BaseController
{
    #[Route('/dashboard', method: 'GET', name: 'dashboard')]
    // resolves to: GET /admin/dashboard   name: admin.dashboard
    public function dashboard(Request $r): Response {}
}

Named Routes & URL Generation

// In PHP
route('users.show', ['id' => 5]);  // → /users/5

{# In Twig #}
{{ route('users.show', {id: 5}) }}

Middleware on Routes

#[Route('/account', method: 'GET', middleware: ['auth'])]
#[Route('/admin',   method: 'GET', middleware: ['auth', 'admin'])]

// AJAX-only route
#[Route('/api/search', method: 'GET', ajax: true)]

Controllers

Creating a Controller

php mvc make:controller PostController

Full Example

namespace App\Controllers;

#[RouteGroup(prefix: '/posts', namePrefix: 'posts.')]
class PostController extends BaseController
{
    #[Route('', method: 'GET', name: 'index')]
    public function index(Request $r): Response
    {
        $posts = Post::where('published', 1)->paginate(15);
        return $this->view('posts/index', compact('posts'));
    }

    #[Route('', method: 'POST', name: 'store')]
    public function store(Request $r): Response
    {
        $data = $this->validate($r->all(), [
            'title'   => 'required|string|max:200',
            'body'    => 'required|string',
        ]);

        $post = Post::create($data);

        return Response::redirect(route('posts.show', ['id' => $post->id]))
            ->with('success', 'Post created!');
    }
}

BaseController Helpers

MethodDescription
$this->view(template, data)Render a Twig template
$this->json(data, status)Return a JSON response
$this->validate(data, rules)Validate input — redirects back on failure
$this->redirect(url)Return a redirect response
$this->back()Redirect to previous URL

Middleware

Creating Middleware

php mvc make:middleware AuthMiddleware

Middleware Class

namespace App\Middleware;

class AuthMiddleware implements MiddlewareInterface
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!auth_check()) {
            return Response::redirect(route('auth.login'))
                ->with('warning', 'Please sign in.');
        }
        return $next($request);
    }
}

Registering Middleware Aliases

// In Application::boot() or a bootstrap file:
$app->middleware()->alias('auth',  App\Middleware\AuthMiddleware::class);
$app->middleware()->alias('admin', App\Middleware\AdminMiddleware::class);
$app->middleware()->alias('guest', App\Middleware\GuestMiddleware::class);

Request

Accessing Input

$request->input('name');            // POST / GET value
$request->input('age', 18);         // with default
$request->all();                     // all input as array
$request->only(['name', 'email']);  // whitelist keys
$request->except(['_token']);       // exclude keys
$request->has('email');             // key present & non-empty
$request->query('page', 1);         // query string only
$request->json();                    // decoded JSON body

Request Information

$request->method();     // GET / POST / PUT / PATCH / DELETE
$request->path();       // /users/5
$request->url();        // http://example.com/users/5
$request->ip();         // client IP address
$request->isAjax();    // true for XMLHttpRequest
$request->isJson();    // Content-Type: application/json
$request->header('Accept');  // request header

File Uploads

$file = $request->file('avatar');

if ($file?->isValid()) {
    $path = $file->store(storage_path('uploads'));
    // $path = storage/uploads/uuid.ext
}

Response

// View response
return $this->view('home/index', ['user' => $user]);

// JSON response
return Response::json(['ok' => true], 201);

// Redirect
return Response::redirect(route('home.index'));

// Redirect back with flash message
return Response::back()
    ->with('success', 'Saved!')
    ->withInput();

// Custom status & headers
return Response::make('Not Found', 404)
    ->header('X-Custom', 'value');

Models

Creating a Model

php mvc make:model Post

Model Definition

namespace App\Models;

use Core\Model;

class Post extends Model
{
    protected string $table       = 'posts';
    protected array  $fillable    = ['title', 'body', 'published'];
    protected array  $hidden      = [];
    protected array  $casts       = ['published' => 'bool', 'views' => 'int'];
    protected bool   $timestamps  = true;
    protected bool   $softDeletes = true;
}

CRUD Operations

// Create
$post = Post::create(['title' => 'Hello', 'body' => '...']);

// Find
$post = Post::find(1);                    // by primary key
$post = Post::findOrFail(1);             // throws 404
$post = Post::where('slug', $slug)->first();

// Update
$post->update(['title' => 'Updated']);

// Delete (soft if $softDeletes = true)
$post->delete();
$post->forceDelete();   // permanent
$post->restore();      // un-delete

// Bulk
Post::all();
Post::count();
Post::where('published', 1)->get();

Available Casts

CastPHP Type
int / integerint
floatfloat
bool / booleanbool
stringstring
arrayarray (JSON decode)
jsonarray/object (JSON decode)
datetimeDateTimeImmutable

Query Builder

Select Queries

$users = Post::where('published', 1)
    ->where('user_id', $userId)
    ->orderBy('created_at', 'desc')
    ->limit(10)
    ->get();

// Or-where
Post::where('status', 'draft')->orWhere('status', 'review')->get();

// Where in
Post::whereIn('id', [1, 2, 3])->get();

// Where null
Post::whereNull('deleted_at')->get();

// Pagination
$posts = Post::paginate(15);  // Paginator instance

Raw DB Access

$db = app('db');

$rows = $db->select('SELECT * FROM posts WHERE id = ?', [1]);
$db->statement('TRUNCATE TABLE sessions');

// Query builder via table
$db->table('users')->where('active', 1)->get();

Aggregates

Post::count();
Post::where('published', 1)->count();
Post::max('views');
Post::sum('views');
Post::avg('rating');

Migrations

Create & Run

php mvc make:migration create_posts_table
php mvc migrate
php mvc migrate:rollback
php mvc migrate:fresh    # drop all + re-run
php mvc migrate:status

Migration Class

use Core\Migration;
use Core\SchemaBuilder;

class CreatePostsTable extends Migration
{
    public function up(SchemaBuilder $schema): void
    {
        $schema->create('posts', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('user_id');
            $t->string('title');
            $t->text('body');
            $t->boolean('published')->default(false);
            $t->timestamps();
            $t->softDeletes();
        });
    }

    public function down(SchemaBuilder $schema): void
    {
        $schema->dropIfExists('posts');
    }
}

Column Types

MethodSQL Type
id()BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
string(col, len)VARCHAR(255)
text(col)TEXT
longText(col)LONGTEXT
integer(col)INT
unsignedBigInteger(col)BIGINT UNSIGNED
boolean(col)TINYINT(1)
decimal(col, p, s)DECIMAL(8,2)
json(col)JSON
timestamp(col)TIMESTAMP
timestamps()created_at + updated_at
softDeletes()deleted_at TIMESTAMP NULL

Seeders

php mvc make:seeder PostSeeder
php mvc db:seed
php mvc db:seed PostSeeder   # run one seeder
class PostSeeder extends Seeder
{
    public function run(): void
    {
        for ($i = 1; $i <= 20; $i++) {
            Post::create([
                'title'     => "Post #{$i}",
                'body'      => 'Lorem ipsum ...',
                'published' => true,
            ]);
        }
    }
}

Views / Twig

PulseMVC uses Twig 3 for all templates. Templates live in app/Views/.

Rendering

// In a controller:
return $this->view('posts/show', ['post' => $post]);

// Or via helper:
return view('posts/show', compact('post'));

Extending a Layout

{% extends 'layouts/app.html.twig' %}

{% block title %}My Page{% endblock %}

{% block content %}
  <h1>Hello, {{ user.name }}!</h1>
{% endblock %}

Available Twig Functions

FunctionDescription
asset(path)URL to a public asset
route(name, params)Generate named route URL
csrf_field()Hidden CSRF input field
csrf_meta()CSRF meta tag for AJAX
flash(key)Get a single flash value
flash_messages()All flash alert messages array
toast_messages()All toast notification data
old(field)Repopulate form field
errors(field)Validation errors for field
has_errors(field)True if field has errors
auth()Current user or null
auth_check()True if user logged in
config(key)Read env/config value
now()Current datetime string

Global Twig Variables

{{ app_name }}     {# APP_NAME from .env #}
{{ app_version }}  {# APP_VERSION #}
{{ app_env }}      {# APP_ENV #}
{{ app_debug }}    {# APP_DEBUG #}
{{ current_user }} {# auth() result #}

Validation

In Controllers

$data = $this->validate($request->all(), [
    'name'     => 'required|string|min:2|max:100',
    'email'    => 'required|email|unique:users,email',
    'password' => 'required|min:8|confirmed',
    'role'     => 'required|in:admin,editor,viewer',
    'age'      => 'nullable|integer|between:18,120',
    'avatar'   => 'nullable|file|mimes:jpg,png|max:2048',
]);

Manual Validator

$v = new Validator($data, $rules, messages: [
    'email.unique' => 'This email is already in use.',
]);

if ($v->fails()) {
    return Response::back()
        ->withErrors($v)
        ->withInput();
}

$clean = $v->validated(); // only validated fields

All Validation Rules

RuleDescription
requiredField must be present and non-empty
nullableField may be empty / null
stringMust be a string
integerMust be an integer
numericInteger or float
boolean1/0/true/false
emailValid email format
urlValid URL
ipValid IPv4 or IPv6
min:nMin length (string) or value (number)
max:nMax length or value
between:min,maxLength or value in range
in:a,b,cValue must be in list
not_in:a,bValue must not be in list
regex:/pattern/Custom regex match
unique:table,colValue not in database
exists:table,colValue must exist in database
confirmedField must match field_confirmation
dateParseable date string
before:dateDate before given date
after:dateDate after given date
fileUploaded file
mimes:jpg,pngAllowed mime extensions

Displaying Errors in Twig

<div class="form-group {% if has_errors('email') %}has-error{% endif %}">
  <input type="email" name="email" value="{{ old('email') }}">
  {% if has_errors('email') %}
    {% for error in errors('email') %}
      <span class="field-error">{{ error }}</span>
    {% endfor %}
  {% endif %}
</div>

Custom Rules

Validator::extend(
    'phone',
    fn($field, $value) => preg_match('/^\+?[0-9]{10,15}$/', $value),
    'The :field must be a valid phone number.'
);

// Use as: 'phone' => 'required|phone'

Session & Flash

Session Usage

$sess = session();

$sess->set('key', 'value');
$sess->get('key');
$sess->has('key');
$sess->forget('key');
$sess->flush();       // clear everything
$sess->regenerate();  // new session ID

// Shorthand helper
session('user_id', 42);  // write
session('user_id');      // read

Flash Messages

// Controller: set flash
return Response::back()
    ->with('success', 'Profile saved!')
    ->with('warning', 'Email not verified.');

// Template: display flash
{% include 'partials/alerts.html.twig' %}

{# Or manually: #}
{% for msg in flash_messages() %}
  <div class="alert alert-{{ msg.type }}">{{ msg.message }}</div>
{% endfor %}

Toast Notifications

// From PHP controller
session()->toast('Profile updated!', 'success', 4000);

// From JavaScript
Toast.show('success', 'Saved!', '', 4000);

JavaScript Modules

All modules are auto-loaded via layouts/base.html.twig. No bundler required.

Toast — toast.js

Toast.show('success', 'Done!', 'optional detail', 4000);
Toast.show('error',   'Something went wrong');
Toast.show('warning', 'Low disk space');
Toast.show('info',    'New version available');

Modal — modal.js

<!-- HTML -->
<button data-modal-open="confirm-dialog">Open Modal</button>

<div id="confirm-dialog" class="modal" aria-hidden="true">
  <div class="modal-overlay" data-modal-close></div>
  <div class="modal-box modal-md">
    <div class="modal-header">
      <h2 class="modal-title">Confirm</h2>
      <button class="modal-close" data-modal-close>&times;</button>
    </div>
    <div class="modal-body">Are you sure?</div>
  </div>
</div>

// JavaScript
Modal.open('confirm-dialog');
Modal.close('confirm-dialog');

// Programmatic creation
Modal.create({ title: 'Alert', body: '<p>Content</p>', size: 'sm' });

Confirm Dialog — confirm.js

<!-- Auto-intercept via attribute -->
<button data-confirm="Are you sure you want to delete this?">Delete</button>

// Programmatic (returns Promise<boolean>)
const ok = await Confirm.show({
  title:       'Delete post?',
  message:     'This action cannot be undone.',
  confirmText: 'Yes, delete',
  type:        'danger',   // danger | warning | info | success
});
if (ok) deletePost();

Tooltip — tooltip.js

<button data-tooltip="Save changes" data-tooltip-placement="top">
  Save
</button>
<!-- placements: top | bottom | left | right (auto-flips at viewport edge) -->

Dropdown — dropdown.js

<div class="dropdown">
  <button class="dropdown-trigger">Actions ▾</button>
  <ul class="dropdown-menu">
    <li><a href="/edit">Edit</a></li>
    <li><a href="/delete" data-confirm="Delete?">Delete</a></li>
  </ul>
</div>

Tabs — tabs.js

<div data-tabs data-tabs-hash>
  <div role="tablist">
    <button role="tab" data-target="tab-1" aria-selected="true">General</button>
    <button role="tab" data-target="tab-2">Security</button>
  </div>
  <div id="tab-1" role="tabpanel">General content</div>
  <div id="tab-2" role="tabpanel" hidden>Security content</div>
</div>

Infinite Scroll — infinite.js

<!-- Auto mode: append when sentinel enters viewport -->
<div data-infinite data-infinite-url="/api/posts">
  <!-- items rendered here -->
</div>
<div data-infinite-sentinel></div>

<!-- Server must return: { "html": "<...>", "nextPage": 2 } -->

Charts — chart.js

<!-- HTML attribute init -->
<canvas data-chart='{"type":"bar","labels":["Jan","Feb","Mar"],
  "datasets":[{"label":"Revenue","data":[120,95,180]}]}'></canvas>

// JavaScript API
const chart = Chart.create('myCanvas', {
  type: 'line',
  labels: ['Jan', 'Feb', 'Mar'],
  datasets: [{ label: 'Users', data: [42, 67, 88] }],
});
chart.update({ data: [50, 75, 92] });
chart.destroy();

Cache

File-based cache with TTL, tagging, and a static facade. Cache files live in storage/cache/.

Basic Usage

// Store for 60 minutes
Cache::put('users.count', $count, 3600);

// Retrieve (returns null if missing/expired)
$count = Cache::get('users.count');

// Remember (fetch-or-store)
$users = Cache::remember('users.all', 3600, fn() => User::all());

// Store forever
Cache::forever('config.settings', $settings);

// Delete
Cache::forget('users.count');
Cache::flush();  // clear entire cache

Tagged Cache

// Write with tags
Cache::tags(['posts'])->put('posts.all', $posts, 3600);
Cache::tags(['posts', 'user-1'])->put('posts.user.1', $data, 1800);

// Flush all posts cache at once
Cache::tags(['posts'])->flush();

CLI

php mvc cache:clear           # all
php mvc cache:clear --type=twig
php mvc cache:clear --type=route

Authentication

Login

$userService = new UserService(app('db'), session());

$user = $userService->attempt($email, $password, $remember);

if ($user) {
    return Response::redirect(route('dashboard'));
}

return Response::back()->with('error', 'Invalid credentials.');

Check Auth & Get User

auth_check();         // bool
auth();               // User model or null
auth('id');          // user ID
auth('is_admin');    // attribute shortcut

In Twig Templates

{% if auth_check() %}
  Welcome, {{ auth().name }}!
  {% if auth().is_admin %}<span class="badge">Admin</span>{% endif %}
{% else %}
  <a href="{{ route('auth.login') }}">Sign in</a>
{% endif %}

Protecting Routes

// Route-level middleware
#[Route('/dashboard', method: 'GET', middleware: ['auth'])]

// Group-level middleware
#[RouteGroup(prefix: '/admin', middleware: ['auth', 'admin'])]

Logging

Log files are written to storage/logs/ by channel (one file per day).

logger('User logged in');                              // debug
logger('Payment failed', 'error');                    // error
logger('Import complete', 'info', ['rows' => 1240]);  // with context

$log = logger();     // Logger instance
$log->info('...');
$log->warning('...');
$log->error('...');
php mvc log:clear        # delete all .log files

Global Helpers

HelperDescription
app(?string)Application / container resolution
env(key, default)Read .env value
config(key, default)Alias for env()
base_path(path)Absolute path from project root
app_path(path)Absolute path from app/
storage_path(path)Absolute path from storage/
public_path(path)Absolute path from public/
database_path(path)Absolute path from database/
asset(path)Public asset URL
url(path)Absolute URL for path
route(name, params)Named route URL
redirect(url)Redirect Response
back()Redirect to Referer
view(template, data)Render Twig template
request(?key)Request instance or input value
session(?key, ?value)Session read/write
logger(?msg, level)Logger / log a message
auth(?key)Current user or attribute
auth_check()Is user logged in?
csrf_token()Current CSRF token string
old(field, default)Previous form input
errors(?field)Validation error messages
has_errors(?field)True if field has errors
abort(status, msg)Throw an HTTP exception
now(format)Current datetime string
dd(...vars)Dump and die (debug)
dump(...vars)Dump without dying

CLI Overview

All commands run via the mvc entry point in the project root.

php mvc               # list all commands
php mvc help serve    # command help

Available Commands

CommandDescription
serve [--host] [--port]Start PHP built-in server
key:generateGenerate APP_KEY in .env
route:listList all registered routes
route:clearDelete route cache
cache:clear [--type]Clear file cache
log:clearDelete all log files
migrateRun pending migrations
migrate:rollbackReverse last migration batch
migrate:resetRoll back all migrations
migrate:freshDrop all tables & re-migrate
migrate:statusShow migration status
db:seed [class]Run seeders
db:backup [--tables] [--keep]Backup database to file
backup:list [--manifest]List database backups
backup:restore [file] [--latest]Restore a backup
make:controller NameScaffold a controller
make:model NameScaffold a model
make:migration nameCreate a migration file
make:middleware NameScaffold middleware
make:seeder NameScaffold a seeder
make:service NameScaffold a service class
make:command NameScaffold a custom command

Make Commands

Scaffolding generators create ready-to-edit stub files in the correct directories.

php mvc make:controller UserController
# → app/Controllers/UserController.php

php mvc make:model Post
# → app/Models/Post.php

php mvc make:migration add_bio_to_users
# → database/migrations/2026_03_31_120000_add_bio_to_users.php

php mvc make:middleware RateLimitMiddleware
# → app/Middleware/RateLimitMiddleware.php

php mvc make:seeder PostSeeder
# → database/seeds/PostSeeder.php

php mvc make:service PaymentService
# → app/Services/PaymentService.php

php mvc make:command SendReportCommand
# → app/Console/SendReportCommand.php

Database Commands

Backup

# Full backup (gzipped)
php mvc db:backup

# Specific tables
php mvc db:backup --tables=users,posts

# Keep last N backups
php mvc db:backup --keep=7

# Custom filename, no gzip
php mvc db:backup --filename=pre-deploy --no-gzip

# Dry run (shows command without executing)
php mvc db:backup --dry-run

List & Restore

php mvc backup:list
php mvc backup:list --manifest

php mvc backup:restore                    # interactive prompt
php mvc backup:restore --latest           # newest backup
php mvc backup:restore backup.sql.gz      # specific file
php mvc backup:restore --latest --force   # skip confirmation

Custom Commands

php mvc make:command SendReportCommand
namespace App\Console;

use Core\CLI\BaseCommand;

class SendReportCommand extends BaseCommand
{
    protected string $name        = 'report:send';
    protected string $description = 'Send the weekly report email';

    public function handle(): int
    {
        $dry = $this->option('dry-run', false);

        $this->info('Generating report...');

        if (!$dry) {
            // send email logic
            $this->success('Report sent!');
        } else {
            $this->warning('Dry run — no email sent.');
        }

        return 0;
    }
}

BaseCommand API

MethodDescription
$this->argument(name)Positional argument value
$this->option(name, default)Flag/option value
$this->info(msg)Print cyan info line
$this->success(msg)Print green success line
$this->warning(msg)Print yellow warning line
$this->error(msg)Print red error line
$this->line(msg)Print plain line
$this->ask(prompt)Prompt for text input
$this->confirm(prompt)Prompt yes/no → bool
$this->choice(prompt, options)Numbered list choice
$this->table(headers, rows)Render ASCII table

Register the Command

// In core/CLI/Console.php — add to $commands array:
App\Console\SendReportCommand::class,
Tip: Run commands via cron with the full path: 0 8 * * 1 /usr/bin/php /var/www/myapp/mvc report:send