Documentation
Velk reference
The essentials to build and maintain a Velk site. The official skill loads this same reference, in full, into your AI.
Getting started
Requirements
- PHP 8.2 or higher
- MySQL 5.7+ or MariaDB 10.2+ (Velk uses native JSON columns)
- Apache with .htaccess (included) — on nginx, write the rewrite rule by hand
Install with AI (recommended)
In the vibe coding flow you download nothing: the official skill handles it. Install it in Claude Code and ask for the install — the AI downloads the official release, verifies the checksum, configures the .env and runs the installer. The step-by-step is in How to use.
Install the elleven-digital/velk-skill skill into ~/.claude/skills/velk and reload the skills.
Manual install (without AI)
For those who prefer to code directly: Velk ships as a versioned tarball — not a repository clone. Download the release, extract it into the project folder, fill in the .env from .env.example (database and initial admin user) and run the installer:
$ ./bin/velk install
creates the DB if missing · runs migrations · creates the admin · copies .htaccess and robots.txt
$ ./bin/velk serve
Velk running at http://localhost:8080
Main .env variables: DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, INITIAL_USER_EMAIL, INITIAL_USER_PASSWORD, INITIAL_USER_NAME and APP_URL (optional locally — used by the sitemap and absolute_url()). SMTP is optional: without SMTP_HOST/USER/PASS/FROM, forms save but do not send email.
Folder structure
core/ engine — never edit
bin/velk CLI
migrations/ engine — applied on install/upgrade
theme/ YOUR site: site.json, templates, assets
storage/ uploads and generated files
.env credentials
.velk-version installed version (used by upgrade)
site.json
theme/site.json is the source of truth for structure: the admin panel is generated from it. Top-level keys:
| Key | What it declares |
|---|---|
| site | name and language. The production URL is not here — it is APP_URL in .env, keeping the theme portable. |
| field_groups | Reusable field bundles, referenced as {"group": "contact"}. |
| image_sizes | Named sizes (width, height, crop) for image_url(). |
| pages | Structural pages by key (e.g. home): label, template, optional URL and fields. |
| page_templates | Templates for the admin "+ Create page" flow — the page lives in the DB, with URL /{slug}. |
| item_types | Content types: label, slug, template, archive_template, taxonomies, alternate templates and fields. |
| taxonomies | Taxonomies: label, slug, hierarchical, template and has_page (false removes the public term URL). |
| options | Global options pages — fields only, no slug or listing. |
| forms | Public forms (fields, email subject, success message). |
| members | Site members area (types, fields, routes). |
| i18n | Multi-language: enabled and default language. |
Templates are bound by file name, never by slug — rename slugs freely without breaking bindings. After any edit, run ./bin/velk schema:validate.
Field types
Common to any field: name (required), label, help, required, placeholder. field('name') returns the raw value — scalar for simple types, array for gallery/repeater/flexible.
| Type | Notes |
|---|---|
| text | Single-line input. |
| textarea | Long text without formatting. |
| richtext | WYSIWYG (TipTap); saves HTML. Print unescaped, commented /* trusted */. |
| image | One media item, stored as an integer id. Resolve with image_url(). |
| gallery | Ordered list of media ids — pass each through image_url(). |
| select | Dropdown; options as {value: label}. |
| boolean | Checkbox. |
| number | Numeric input. |
| email · url · date | HTML5 input variants. |
| repeater | Repeating group of subfields; declare fields on the field. |
| flexible | Repeater where each row picks a named layout; the saved row carries _layout. |
| link | Composite {title, url, new_tab}; print with link_tag(). Accepts mailto:, anchors and relative paths. |
| item | Reference to another item (combobox, published only). Declare item_type; resolve with item(field('x')). |
| group | Organization only: collapsible box in the admin. Storage stays flat. |
| tabs | Organization only: tabs in the editor. Two tabs cannot reuse a field name. |
Templates
Resolution order:
- Structural page: template → fallback page.php
- Admin-created page: file of the chosen template → page.php
- Item: the item's own template (if any) → the type's template → single.php
- Item archive: archive_template → archive.php
- Taxonomy archive: the taxonomy's template → taxonomy.php
<?php with_context($item, function () {
get_header(); ?>
<article class="post">
<h1><?= e(the_title()) ?></h1>
<?= field('content') /* trusted */ ?>
</article>
<?php get_footer(); }); ?>
with_context() lets field(), the_title() and friends work without passing the record around. Each template type gets one context variable:
| Template | Variable | Contents |
|---|---|---|
| page-*.php | $page | ->record is the page |
| single-*.php | $item | ->record is the item |
| archive-*.php | $archive | ->records is the list of items |
| taxonomy-*.php | $term | ->record is the term, ->records the items |
Helpers
All global in templates (defined in core/helpers.php).
Output and context
| Helper | Does |
|---|---|
| e($value) | Escapes for HTML. Use on all output — except trusted richtext. |
| dd(...$v) | Debug: dumps and exits. |
| field($name, $default) | Field from the current context (language already resolved). |
| the_title() · the_slug() · the_url() | Basics of the current record. |
| with_context($ctx, $fn) | Runs the block with that record as context. |
Content
| Helper | Does |
|---|---|
| items($type, $args) | Lists items — limit, offset, status (default published), term, order. |
| item($id) | One item (or null) — resolves item fields. |
| terms($taxonomy) | Terms of a taxonomy. |
| the_terms($taxonomy) | Terms of the current item. |
URLs and media
| Helper | Does |
|---|---|
| url($path) | Navigation link with the install base path (and active language). |
| absolute_url($path) | Full URL (scheme + host) — canonical, og:url, sitemap. |
| asset($path) · admin_url() · base_path() | Theme assets, admin URL, install base. |
| image_url($value, $size) | Resolves id/URL/array to a URL; '' when empty — check before the <img>. |
| image_alt($value, $fallback) | Alt text (language-aware). |
| media($id) | The media model (width/height/mime). |
| link_tag($link, $text, $attrs) | Safe <a> for a link field; a new tab gets rel="noopener". |
Layout and misc
| Helper | Does |
|---|---|
| get_header() · get_footer() | Include theme/partials/header.php and footer.php. |
| partial($name, $data) | Includes a partial with data. |
| option($path, $default) · options($key) | Global options by dot path (option('contact.phone')). |
| slugify($string) · config($key) · site($key) | Utilities. |
URLs & routes
- / → home page
- /{page-key} → other structural pages (or the explicit URL declared)
- /{type-slug} → archive · /{type-slug}/{item-slug} → item
- /{taxonomy-slug}/{term-slug} → term archive
- /admin → panel · /uploads/... → media · /theme/... → static theme assets (no PHP)
The base path is auto-detected from SCRIPT_NAME, so the site works installed at the root or in a subdirectory (site.com/velk/) with no configuration — as long as the theme always uses the URL helpers. If an exotic host gets detection wrong: 'base_path' => '/velk' in config/env.php.
Media
Library at /admin/media: upload (jpeg, png, gif, webp, svg, pdf — up to 25 MB), alt text, copy URL and deletion that cleans up generated sizes. File names are slugified; collisions get a -1, -2… suffix.
storage/uploads/{file}.{ext} originals
storage/uploads/thumb/{file}.{ext} generated on demand (GD)
storage/uploads/hero/{file}.{ext} generated on demand (GD)
Sizes come from image_sizes in site.json — crop: true crops exactly, false fits proportionally. In the theme: image_url(field('photo'), 'thumb').
Global options
"options": {
"contact": {
"label": "Contact",
"fields": [
{ "name": "phone", "type": "text", "label": "Phone" }
]
}
}
Each entry becomes a sidebar link and a fields-only form. Read with option('contact.phone') — the dot path reaches into groups and repeater rows (option('contact.social.0.url')). A whole page comes out with options('contact'). Stored as one JSON row in settings, cached in process: 50 calls, one query.
Forms
"forms": {
"contact": {
"label": "Contact",
"subject": "New contact: {{name}}",
"success_message": "Message sent.",
"fields": [
{ "name": "name", "type": "text", "label": "Name", "required": true }
]
}
}
Accepted types: text, email, tel, url, number, textarea, checkbox, hidden, select (with required and maxlength). The subject accepts {{field}} placeholders filled from the submission.
The theme writes the form HTML:
- form_url('contact') in the action (POST)
- csrf_field() + form_honeypot() inside the form
- form_status('contact') to read the flash — status, message, values and errors
Cycle: validates CSRF + honeypot + fields, saves to form_submissions, emails the recipients set at /admin/forms/contact and redirects back. A failed email does not lose the submission.
Members
Accounts for the public site — tables, authentication and sessions separate from admin users. No predefined roles: each site declares its own types and the theme decides what each can access.
"members": {
"enabled": true,
"types": {
"client": { "label": "Client",
"fields": [ { "name": "company", "type": "text" } ] }
},
"routes": { "login": "/login" }
}
| Helper | Does |
|---|---|
| member_enabled() · member_check() | Feature on? Anyone logged in? |
| member() · member_field('x') · member_is('type') | Current member, their field, type check. |
| member_require() · member_require_type('type') | Gates the page; redirects those who cannot. |
| member_route('login') · member_status() | Resolved route and last auth flash. |
Public registration is off (public_registration: false) — by default only admins create accounts. The theme builds the login/register/reset forms against the engine's POST endpoints, always with csrf_field().
Multi-language
Three independent layers, all native:
- Fixed theme strings — wrap in t('Read more'); catalog built by i18n:scan and translated at /admin/translations.
- Structural URLs — slugs of pages, types and taxonomies per language: /blog/post-x becomes /en/news/post-x.
- Per-record content — title, slug and fields with *_translations columns and language tabs in the editor itself.
"i18n": { "enabled": true, "default": "en" }
The other languages are registered at /admin/languages, each with a URL prefix. In templates nothing changes: field(), the_title() and option() already resolve the active language with fallback to the default — write the template once.
| Helper | Does |
|---|---|
| lang() · lang_is('en') · available_languages() | Current language and language list. |
| url('/about', 'en') · lang_url('en') | URL in another language; the current page in another language. |
| lang_switcher() | Ready-made data to build the language selector. |
| the_html_lang() · the_hreflangs() · the_canonical() | The <html> attribute, alternate links and canonical. |
Non-default language URLs are strict — no translated slug, no URL in that language (no duplicate content for Google). The sitemap emits hreflang only when the translation really exists.
CLI
./bin/velk <command>, from the project root. Almost everything the panel does, the CLI does — that is what lets the AI run the site.
Setup and schema
| Command | Does |
|---|---|
| install | Full install: database, migrations, admin, project files. |
| migrate · migrate:status | Applies / lists pending migrations. |
| schema:validate | Validates the site.json — run after every edit. |
| page:sync | Creates/updates the page rows declared in the schema. |
| serve [port] | Dev server (default :8080). |
| user:create | New admin user. |
| files:init | Copies .htaccess and robots.txt from the .example files. |
| i18n:scan | Scans the theme for t() and registers the strings. |
Content
| Command | Does |
|---|---|
| item:types · item:schema <type> | Lists types; shows a type's fields (read before writing). |
| item:list · item:get | Lists (with filters) and reads an item as JSON. |
| item:create --json= · item:update --json= | Creates and updates (merge) by JSON. |
| item:publish · item:unpublish · item:delete --confirm | Changes status; deletes (requires --confirm). |
| page:list · page:schema · page:get · page:update | The same, for pages. |
Useful flags
- --format=json — machine-readable output
- --dry-run — validates without saving
- --json-stdin — payload via stdin, no shell quoting hell
Deploy & upgrades
Deploy
- Shared hosting without SSH: upload the files over FTP/panel — auto-migrate completes the install on the first request.
- With SSH (VPS, cloud, cPanel with terminal): the official skill covers the whole flow — files, database, storage and robots.txt with the production URL.
- Subdirectory: works with no configuration, because the URL helpers prefix the base path themselves.
In production, fill in APP_URL in .env — the sitemap and absolute_url() depend on it.
Upgrades
The engine is a versioned dependency, distributed as a tarball with a checksum. The upgrade reads the version in .velk-version, downloads the new one, verifies the sha256 and replaces only the engine paths — theme/, storage/, .env, .htaccess and robots.txt stay as they are. Ask the AI: "update Velk in this project".
The full reference lives in the skill
Install the official skill and your AI starts consulting this entire documentation — schema, helpers, CLI and deploy flows — without leaving the chat.