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:

Download the latest version

terminal

$ ./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

my-site/
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)
Golden rule: everything that is yours lives in theme/ (plus theme/functions.php for load-time PHP). The upgrade swaps core/, bin/ and migrations/ whole — edits there evaporate.

site.json

theme/site.json is the source of truth for structure: the admin panel is generated from it. Top-level keys:

KeyWhat it declares
sitename and language. The production URL is not here — it is APP_URL in .env, keeping the theme portable.
field_groupsReusable field bundles, referenced as {"group": "contact"}.
image_sizesNamed sizes (width, height, crop) for image_url().
pagesStructural pages by key (e.g. home): label, template, optional URL and fields.
page_templatesTemplates for the admin "+ Create page" flow — the page lives in the DB, with URL /{slug}.
item_typesContent types: label, slug, template, archive_template, taxonomies, alternate templates and fields.
taxonomiesTaxonomies: label, slug, hierarchical, template and has_page (false removes the public term URL).
optionsGlobal options pages — fields only, no slug or listing.
formsPublic forms (fields, email subject, success message).
membersSite members area (types, fields, routes).
i18nMulti-language: enabled and default language.
SEO is free: every type with a page gets meta_title, meta_description and og_image automatically — do not redeclare these fields.

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.

TypeNotes
textSingle-line input.
textareaLong text without formatting.
richtextWYSIWYG (TipTap); saves HTML. Print unescaped, commented /* trusted */.
imageOne media item, stored as an integer id. Resolve with image_url().
galleryOrdered list of media ids — pass each through image_url().
selectDropdown; options as {value: label}.
booleanCheckbox.
numberNumeric input.
email · url · dateHTML5 input variants.
repeaterRepeating group of subfields; declare fields on the field.
flexibleRepeater where each row picks a named layout; the saved row carries _layout.
linkComposite {title, url, new_tab}; print with link_tag(). Accepts mailto:, anchors and relative paths.
itemReference to another item (combobox, published only). Declare item_type; resolve with item(field('x')).
groupOrganization only: collapsible box in the admin. Storage stays flat.
tabsOrganization only: tabs in the editor. Two tabs cannot reuse a field name.

Templates

Resolution order:

  1. Structural page: template → fallback page.php
  2. Admin-created page: file of the chosen template → page.php
  3. Item: the item's own template (if any) → the type's template → single.php
  4. Item archive: archive_template → archive.php
  5. Taxonomy archive: the taxonomy's template → taxonomy.php
theme/templates/single-post.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:

TemplateVariableContents
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

HelperDoes
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

HelperDoes
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

HelperDoes
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

HelperDoes
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.

Never write /blog by hand in a template — the day the site moves folder, it breaks. url('/blog') does not.

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
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

site.json — 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

site.json — 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.

site.json — members
"members": {
  "enabled": true,
  "types": {
    "client": { "label": "Client",
      "fields": [ { "name": "company", "type": "text" } ] }
  },
  "routes": { "login": "/login" }
}
HelperDoes
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.
site.json — i18n
"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.

HelperDoes
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

CommandDoes
installFull install: database, migrations, admin, project files.
migrate · migrate:statusApplies / lists pending migrations.
schema:validateValidates the site.json — run after every edit.
page:syncCreates/updates the page rows declared in the schema.
serve [port]Dev server (default :8080).
user:createNew admin user.
files:initCopies .htaccess and robots.txt from the .example files.
i18n:scanScans the theme for t() and registers the strings.

Content

CommandDoes
item:types · item:schema <type>Lists types; shows a type's fields (read before writing).
item:list · item:getLists (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 --confirmChanges status; deletes (requires --confirm).
page:list · page:schema · page:get · page:updateThe same, for pages.

Useful flags

  • --format=json — machine-readable output
  • --dry-run — validates without saving
  • --json-stdin — payload via stdin, no shell quoting hell
Auto-migrate: an installed site applies pending migrations by itself on the first web request after an upgrade (concurrent requests are serialized with a MySQL lock). A host without SSH finishes the upgrade just by uploading the files. Disable with AUTO_MIGRATE=false.

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.