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/.
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/
├── 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.
- Tier 1 — Pattern routes. Explicit routes registered with
get(),post(), groups, params, and constraints. - Tier 2 — Legacy
add()routes. Exact, ANY-method routes kept for backward compatibility (registered as'*'). - Tier 3 — Controller auto-discovery. Maps URI segments onto a controller, method, and positional args.
- Tier 4 — View fallback. A traversal-guarded template lookup under
templates/default/at arbitrary depth, rendered byPageController::show(). - Tier 5 — 404. Renders the
404view.
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.
{slug}— required single segment, compiles to(?P<slug>[^/]+).{id:\d+}— constrained: everything after the colon is injected verbatim into the regex, so{id:\d+}becomes(?P<id>\d+).{id?}— optional. When the preceding literal ends in/, that slash is made optional too.{path:.*}— catch-all: the.*constraint matches across/, so a single param can span/a/b/c/d.
HTTP verb helpers
Each helper returns the Route so you can chain ->name() or ->middleware():
get($p, $h, $mw = [])— maps GET and HEAD.post(),put(),patch(),delete()— the single matching verb.any()— maps'*'(every method).match(array $methods, …)— an arbitrary list of verbs.add($route, $controller, $mw = [])— legacy ANY-method ('*') registration (tier 2).
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.
// 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().
// 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.
$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-page →
MyPageController), 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.
// 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
$r->param('slug', $default)— a matched route parameter.$r->input('title', $default)— a request body / query value.$r->ip()— the client IP, Cloudflare-aware:CF-Connecting-IP→ firstX-Forwarded-Forhop →REMOTE_ADDR.$r->wantsJson()— whether the client expects JSON (used by the auth and CSRF middleware to choose JSON vs redirect).
Building responses
Response::make($body, $status = 200)— a plain response.Response::json($data, $status = 200)— a JSON response.Response::redirect($url)— a redirect.Response::view($name, $data)— render a view into the response.$response->withHeader($name, $value)— add a header (chainable).
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.
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
- Per route: pass an alias array as the 3rd arg (e.g.
['csrf'],['auth']) or chain->middleware(['auth'])(Route::middleware()array_merges an array). - Register an alias once:
Router::aliasMiddleware('json', EnsureJson::class), resolved at dispatch byresolveMiddleware()(only instances ofMiddlewareInterfaceare kept). - Global:
Router::globalMiddleware(['json'])runs on every matched route, merged ahead of the route's own middleware.
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.
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.
# 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
- auth — accounts, login/logout, roles; provides
guest+authmiddleware. - payments — Stripe + PayPal via dependency-free REST clients.
- blog-cms — SQLite blog with auth-protected admin and
{id:\d+}routes. - contact-mailer — honeypot + CSRF form via the MailerSend API.
- admin-dashboard — dark admin shell + reusable layout.
- rest-api-kit — hashed API keys +
apikeymiddleware +/api/v2group (below). - indexnow — serves the key file and submits URLs to IndexNow.
- google-analytics-gsc — read-only GSC + GA4 dashboard.
- google-ecommerce — Merchant Center product feed.
- schema-generator — stateless JSON-LD & SITE_* snippet generator.
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
- CSRF (two modes). The
csrfmiddleware is non-fatal —Security::csrfValid()on POST/PUT/PATCH/DELETE, accepting acsrf_tokenfield orX-CSRF-Tokenheader, returning419.Security::verifyCsrfToken()is the fatal variant: a constant-timehash_equals()check that logs the client IP, sends403, anddie()s. Tokens come fromSecurity::generateCsrfToken()(32-byte hex in the session). - Output escaping.
Security::e()wrapshtmlspecialchars(…, ENT_QUOTES, 'UTF-8'); use it for all dynamic output. - Base headers. Emitted on every dispatch by
Router::sendBaseHeaders():X-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, andPermissions-Policy: geolocation=(), microphone=(), camera=(). - Traversal-guarded routing. The view fallback rejects
..and NUL bytes, validates every segment against^[a-z0-9][a-z0-9\-]*$, andrealpath()-verifies the resolved template stays insidetemplates/default. - Hardened sessions. HttpOnly / Secure / SameSite cookies with regeneration (deepened by the auth addon).
- Data & secrets. PDO prepared statements for all DB access; secrets kept
in
TiCore/.envoutside the web root, withsecrets:scanand a gitleaks CI workflow keeping credentials out of commits.
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.
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.