Skip to main content

TiCore v2 Documentation

A complete, copy-paste-ready guide to building with TiCore v2 — installation, the five-tier router, requests & responses, the middleware pipeline, controllers & views, the addon ecosystem and ticore CLI, the security model, and the built-in SchemaBuilder SEO suite.

Prefer a feature-by-feature view? See the framework comparison, the addon catalog, the CLI reference, and the security model, or read the source on GitHub.

Installation

TiCore uses a public/private split: the web root is www/, and everything sensitive — config, routes, controllers, templates, addons, and the .env secrets — lives in TiCore/ outside it. Clone the repo, copy the example env, and point your document root at www/.

Install commands
git clone https://github.com/tuxxin/TiCore.git
cd TiCore
cp TiCore/.env-example TiCore/.env   # set SITE_TITLE, BASE_URL, secrets

# point your web server's docroot at www/ (Apache mod_rewrite or Nginx)
php bin/ticore key:generate          # print a 32-byte hex app key for .env

Drop a view into TiCore/templates/default/ or a controller into TiCore/src/Controllers/ and it serves with zero config — that is the auto-discovery fallback covered under Routing. Declare explicit routes in TiCore/routes/web.php (and routes/api.php), where the $router instance is in scope. Project layout:

Project layout
project/
├── TiCore/                  # framework + your app (NOT web-accessible)
│   ├── config.php           # constants (BASE_URL, SITE_*, …) + .env loading
│   ├── .env                 # secrets, kept outside the web root
│   ├── routes/              # web.php, api.php
│   ├── src/Core/            # Router, Route, Http/, Middleware/, Seo/, Security, Logger
│   ├── src/Controllers/
│   ├── templates/default/   # views + layouts
│   ├── addons/              # installed drop-ins (TiCore/addons/<slug>)
│   └── database/            # sqlite (gitignored)
├── bin/ticore               # CLI
└── www/                     # public web root: index.php, assets, .htaccess

Routing

Every request flows through Router::dispatch(), which resolves the URI through a deterministic five-tier cascade. The first tier that matches wins, so explicit routes always take precedence while zero-config discovery remains a backward-compatible fallback.

  1. Tier 1 — Pattern routes. Explicit routes registered with get(), post(), groups, params, and constraints.
  2. Tier 2 — Legacy add() routes. Exact, ANY-method routes kept for backward compatibility (registered as '*').
  3. Tier 3 — Controller auto-discovery. Maps URI segments onto a controller, method, and positional args.
  4. Tier 4 — View fallback. A traversal-guarded template lookup under templates/default/ at arbitrary depth, rendered by PageController::show().
  5. Tier 5 — 404. Renders the 404 view.

Both pattern and legacy routes live in the same $routes[] array and are tried in tiers 1 and 2. If a route's path matched but its method did not, dispatch sends 405 Method Not Allowed. A genuine path miss does not 405 — it falls through to tiers 3–5.

Parameters & constraints

Parameters are written {name} in the pattern. Route::compile() preg_quote()s the literal text and turns each placeholder into a named capture group; matched values are rawurldecode()'d before reaching your handler.

HTTP verb helpers

Each helper returns the Route so you can chain ->name() or ->middleware():

Because browsers only send GET/POST, Request::capture() honors a method override: a _method POST field or an X-HTTP-Method-Override header. The override is gated to a POST origin and only upgrades the request to PUT, PATCH, or DELETE — ideal for HTML forms that need those verbs.

Route definitions: params, constraints, verbs, override
// TiCore/routes/web.php   ($router in scope)
use TiCore\Core\Http\Request;
use TiCore\Core\Http\Response;

$router->get('/', 'HomeController@index')->name('home');

// Required single-segment param + named route
$router->get('/blog/{slug}', 'BlogController@show')->name('blog.show');

// Constrained param ({id:\d+}) returning JSON
$router->get('/post/{id:\d+}', fn(Request $r) =>
    Response::json(['id' => (int) $r->param('id')]));

// Optional param
$router->get('/users/{id?}', 'UserController@show');

// Catch-all, unlimited depth (matches /docs/a/b/c/d)
$router->get('/docs/{path:.*}', 'DocsController@show');

// PUT/PATCH via match() + HTML-form override (_method=PUT on a POST)
$router->match(['PUT', 'PATCH'], '/post/{id:\d+}', 'PostController@update', ['auth']);
$router->delete('/post/{id:\d+}', 'PostController@destroy', ['auth']);

// CSRF-guarded write
$router->post('/contact', 'ContactController@submit', ['csrf']);

Named routes & reverse URLs

Name a route with ->name(), then build its URL anywhere with the global route() helper (backed by Router::routeUrl() / Route::url()). It prefixes BASE_URL when defined and returns an absolute URL. Params are substituted into the pattern, duplicate slashes are collapsed, multi-segment catch-all values are split on / and each segment is rawurlencode()'d, and any leftover params become a ?query string via http_build_query().

Reverse URL generation
// With BASE_URL = https://example.com defined in config:
echo route('blog.show', ['slug' => 'hello-world']);
// => https://example.com/blog/hello-world

// Leftover params become a query string:
echo route('blog.show', ['slug' => 'hello', 'ref' => 'nav']);
// => https://example.com/blog/hello?ref=nav

Groups

Router::group() shares a prefix and middleware across many routes. Groups are nestable: the group stack accumulates all prefixes and merges group middleware ahead of each route's own middleware.

Route group example
$router->group(['prefix' => 'admin', 'middleware' => ['auth']], function ($router) {
    $router->get('/dashboard', 'Admin\DashboardController@index')->name('admin.dashboard');
    $router->post('/posts', 'Admin\PostController@store', ['csrf']);
    // Nest deeper — prefixes and middleware accumulate on the stack:
    $router->group(['prefix' => 'posts', 'middleware' => ['csrf']], function ($router) {
        $router->post('/{id:\d+}/publish', 'Admin\PostController@publish');
    });
});

Auto-discovery (tier 3) & the view fallback (tier 4)

When no explicit route matches, TiCore tries controller auto-discovery (Router::autoDiscover()): segment 0 becomes a <Ucwords>Controller (hyphens removed, so my-pageMyPageController), segment 1 becomes the method (lcfirst, hyphens removed), and any remaining segments are passed as positional arguments. / maps to ['home'], and if the resolved method doesn't exist the controller's index() is called. Every segment must match ^[a-z0-9][a-z0-9\-]*$; controllers load from CORE_PATH/src/Controllers/ under CONTROLLER_NS (default TiCore\Controllers).

If no controller resolves, the view fallback (Router::viewFallback()) resolves an arbitrary-depth template at templates/default/<path>.php. It is traversal-guarded: it rejects .. and NUL bytes, validates each segment against ^[a-z0-9][a-z0-9\-]*$, then realpath()-verifies the resolved file still lives inside templates/default before rendering via PageController::show(). This Docs page itself is served by the view fallback.

Auto-discovery segment mapping
// Tier 3 — controller auto-discovery (no explicit route needed):
//   /              -> HomeController::index()
//   /reports       -> ReportsController::index()
//   /reports/view  -> ReportsController::view()
//   /reports/view/42 -> ReportsController::view(42)   // seg2+ = positional args
//   (method missing) -> falls back to the controller's index()

// Tier 4 — view fallback (no controller at all):
//   /docs          -> templates/default/docs.php   (this page)
//   /guides/intro  -> templates/default/guides/intro.php

Requests & Responses

Type-hint TiCore\Core\Http\Request on your handler's first parameter and the router passes the captured request; otherwise it spreads the matched route params positionally. Request::capture() builds the request from the PHP superglobals (and applies the method override described above).

Reading input

Building responses

You can also just echo: Router::invoke() wraps handlers in an output buffer, so echoed output is captured into the Response. A returned Response is used as-is; a returned string is appended.

Request and Response example
use TiCore\Core\Http\{Request, Response};

$router->get('/hello/{name}', fn(Request $r) =>
    Response::make('Hello, ' . e($r->param('name'))));

$router->get('/api/me', fn(Request $r) =>
    Response::json([
        'ip'        => $r->ip(),         // Cloudflare-aware
        'wantsJson' => $r->wantsJson(),
    ])->withHeader('Cache-Control', 'no-store'));

Middleware

Middleware implements TiCore\Core\Middleware\MiddlewareInterface, whose single method is handle(Request $request, callable $next): Response. Run logic before, call $next($request) to continue down the onion, act on the Response on the way back out, or return a Response early to short-circuit. The dependency-free Pipeline runs the stack by wrapping each layer around a core destination.

Registering & attaching

Custom middleware + alias registration
namespace App\Middleware;

use TiCore\Core\Middleware\MiddlewareInterface;
use TiCore\Core\Http\{Request, Response};

final class EnsureJson implements MiddlewareInterface
{
    public function handle(Request $request, callable $next): Response
    {
        if (!$request->wantsJson()) {
            return Response::json(['error' => 'JSON only'], 406); // short-circuit
        }
        $response = $next($request);                     // continue the onion
        return $response->withHeader('X-Api', 'TiCore');  // act on the way out
    }
}

// In bootstrap (e.g. www/index.php) before dispatch:
$router->aliasMiddleware('json', \App\Middleware\EnsureJson::class);
// $router->globalMiddleware(['json']);   // run on every matched route
$router->get('/api/v2/data', 'DataController@index', ['json']);

Built-in: csrf

CsrfMiddleware (alias csrf) fires only on POST/PUT/PATCH/DELETE — safe methods pass straight through. It accepts either a csrf_token field or the X-CSRF-Token header and calls Security::csrfValid(). On failure it returns Response::make('Invalid CSRF token', 419). It is non-fatal, unlike Security::verifyCsrfToken() which sends 403 and die()s (see Security).

Built-in: auth

AuthMiddleware (alias auth) is a base session gate: it starts the session if needed and treats the user as authenticated when $_SESSION['user_id'] or $_SESSION['logged_in'] is set. On failure it branches on wantsJson() — returning Response::json(['error' => 'Unauthenticated'], 401) for API clients, otherwise Response::redirect(LOGIN_URL ?? '/login'). The auth addon extends this with full accounts and roles, and adds a guest alias.

Controllers & Views

A handler can be a 'Controller@method' string (resolved via resolveController()class_exists short-circuits, the name is validated against ^[A-Za-z][A-Za-z0-9_]*$, then loaded from src/Controllers/ under CONTROLLER_NS), any callable/closure, or an [object, 'method'] pair. Router::callWithParams() reflects the handler: if the first parameter type-hints Request it receives the request, otherwise the matched route params are spread positionally.

Controller returning a Response
namespace App\Controllers;

use TiCore\Core\Http\{Request, Response};
use TiCore\Core\Security;

final class BlogController
{
    // Request injected because the first param type-hints it.
    public function show(Request $req): Response
    {
        $slug = $req->param('slug', '');
        // ... load the post by $slug (PDO prepared statement) ...
        return Response::view('blog-cms::post', [
            'title' => Security::e($slug),   // escape ALL dynamic output
        ]);
    }

    // CSRF-guarded write (route attaches ['csrf']).
    public function store(Request $req): Response
    {
        $title = (string) $req->input('title', '');
        // ... insert ...
        return Response::redirect(route('blog.index'));
    }
}

Views live in templates/default/. Thanks to the tier-4 view fallback, a file like about.php serves at /about with no route or controller. Addons expose namespaced views (e.g. blog-cms::post). Always escape dynamic output with e() (the helper around Security::e() = htmlspecialchars(…, ENT_QUOTES, 'UTF-8')).

Addons & the ticore CLI

Drop-in features live under TiCore/addons/<slug>/ and are pulled separately from the base. Each ships an addon.json manifest — including a middleware alias map — plus a register() step that wires the addon into the router (registering its middleware aliases via Router::aliasMiddleware() and loading its route files via Router::loadRoutes(), e.g. routes/web.php and routes/api.php).

Installing with the CLI

bin/ticore addon add <slug> validates the slug (^[a-z0-9][a-z0-9\-]*$), fetches the addon from the public repo (tuxxin/TiCore, branch HEAD) into TiCore/addons/<slug>, reads its addon.json, then downloads each file in the manifest's files[] list from raw.githubusercontent.com. It refuses if the addon is already installed. addon remove deletes the directory but leaves any DB tables intact.

CLI workflow
# Addons (fetched from tuxxin/TiCore @ HEAD into TiCore/addons/<slug>)
bin/ticore addon list
bin/ticore addon add auth
bin/ticore addon add rest-api-kit
bin/ticore addon remove payments     # DB tables left intact

# Project utilities
bin/ticore key:generate              # 32-byte hex secret for .env
bin/ticore secrets:scan              # scan for committable secrets (exit 1 on hit)
bin/ticore migrate                   # run addon migrations (many self-migrate)
bin/ticore help                      # usage summary

Many addons are self-migrating — for example the auth addon creates its SQLite tables on first boot — so migrate often has nothing to run. secrets:scan detects GitHub/Stripe/AWS/Google/GCP/PEM/AdSense and inline-credential patterns before you push. See the full CLI reference for every command.

Available addons

Flagship: REST API Kit

The rest-api-kit addon adds an apikey middleware (ApiKeyAuth) that gates a versioned /api/v2 route group. It enforces X-Api-Key header-only auth (no query fallback) — keys are SHA-256 hashed, shown once, and verified by prefix lookup + hash_equals — and applies per-IP token-bucket rate limiting that returns 429 Too Many Requests with a Retry-After header (local/exempt IPs are skipped). A server-side proxy console keeps keys out of the browser.

Security

Read the full breakdown on the security model page.

SEO & SchemaBuilder

Beyond the layout-level canonical, Open Graph, Twitter/X, and sitemap tags, TiCore ships TiCore\Core\Seo\SchemaBuilder — a config-driven schema.org JSON-LD builder. SchemaBuilder::script($ctx) returns a pretty-printed {"@context":"https://schema.org","@graph":[…]} string; SchemaBuilder::graph($ctx) returns just the @graph array. The default graph always emits three nodes — WebSite, Organization, and a WebPage that links isPartOf the WebSite and publisher the Organization.

The $ctx keys are baseUrl, siteName, canonical (defaults to baseUrl), description, pageTitle, logo, facebook (appended to sameAs), and extra — an array of additional nodes appended verbatim to the graph for per-page Product / Article / FAQ / Breadcrumb structured data.

SchemaBuilder: base graph + a Product node
use TiCore\Core\Seo\SchemaBuilder;

$ctx = [
    'baseUrl'     => 'https://example.com',
    'siteName'    => 'Example',
    'canonical'   => 'https://example.com/product/widget',
    'pageTitle'   => 'Widget — Example',
    'description' => 'A great widget.',
    'logo'        => 'https://example.com/logo.png',
    'facebook'    => 'https://facebook.com/example',
    // Per-page node(s) appended verbatim to the @graph:
    'extra' => [[
        '@type'  => 'Product',
        'name'   => 'Widget',
        'offers' => ['@type' => 'Offer', 'price' => '9.99', 'priceCurrency' => 'USD'],
    ]],
];

// In <head>: emits WebSite + Organization + WebPage + the Product node.
echo '<script type="application/ld+json">' . SchemaBuilder::script($ctx) . '</script>';

The Organization and WebPage nodes are further enriched by optional config constants (all with neutral defaults): SITE_ORG_NAME, SITE_ORG_URL, SITE_ORG_TYPE, SITE_PAGE_TYPE, SITE_SAMEAS, SITE_CATEGORY, SITE_CONTACT_EMAIL/PHONE/TYPE (→ ContactPoint), SITE_ADDRESS (→ PostalAddress), and SITE_RATING + SITE_RATING_COUNT (→ AggregateRating). Define them by hand or generate the matching PHP snippet with the schema-generator addon. For instant indexing, the indexnow addon submits your URLs (including the sitemap) to the IndexNow API.

Share: 𝕏 Twitter Facebook LinkedIn
Proton VPN — 10 device connections, audited no-logs
Recommended: Proton Unlimited
VPN, encrypted Drive, encrypted Mail, password manager — one bundle. See the full review →