Hosting with 20% off when you buy through the link.

Documentation

Velk reference

The essentials to build and maintain a Velk site. The official skill loads this same reference, in full, into your AI.

  • site.json
  • field()
  • image_url()
  • option()
  • bin/velk
  • t()

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 .env and runs the installer. The step-by-step is in How to use.

Install Velk in this folder and create my admin user.

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 a partir do .env.example (database and initial admin user) and run the installer:

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 variables of the .env: 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 by absolute_url()). SMTP is optional: without SMTP_HOST/USER/PASS/FROM, forms still save but send no 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 the upgrade)
Golden rule: everything that is yours lives in theme/ (plus theme/functions.php for load-time PHP). The upgrade replaces core/, bin/ e migrations/ entirely: edits in there evaporate.

site.json

theme/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 e 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. ./bin/velk schema:validate.

Field types

Common to every field: name (required), label, help, required, placeholder. field('name') returns the raw value: a scalar for simple fields, an 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 page.php
  2. Admin-created page: file of the chosen template → page.php page.php
  3. Item: the item's own template (if any) → the type's template → single.php single.php
  4. Item archive: archive_template → archive.php archive.php
  5. Taxonomy archive: the taxonomy's template → taxonomy.php 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() deixa field(), the_title() with_context() lets field(), the_title() and friends work without passing the record around. Each template type gets one context variable:

TemplateVariableContent
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). 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 &lt;a&gt; for a link field; a new tab gets rel=&quot;noopener&quot;.

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 &amp; routes

  • / &rarr; home page
  • /{page-key} /{page-key} → other structural pages (or the explicit URL declared)
  • /{type-slug} &rarr; archive · /{type-slug}/{item-slug} &rarr; item
  • /{taxonomy-slug}/{term-slug} &rarr; term archive
  • /admin &rarr; panel · /uploads/... &rarr; media · /theme/... &rarr; 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. 'base_path' =&gt; '/velk' em config/env.php.

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

Media

Biblioteca em /admin/mediaLibrary 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}          originais
storage/uploads/thumb/{file}.{ext}    gerado sob demanda (GD)
storage/uploads/hero/{file}.{ext}     gerado sob demanda (GD)

The sizes come from image_sizes in site.json: crop: true cuts 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 in the admin and a fields-only form. Read it with option('contact.phone'): the dot path reaches into groups and repeater rows (option('contact.social.0.url')). Everything from one page comes out with options('contact'). Stored as a single 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. {{field}} filled in with the submission.

The theme writes the form HTML:

  • form_url('contact') no action (POST)
  • csrf_field() + form_honeypot() inside the form
  • form_status('contact') to read the flash: status, message, values and errors

The cycle: validates CSRF + honeypot + fields, saves to form_submissions, emails the recipients set in /admin/forms/contact and redirects back. A failed email never loses 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(). csrf_field().

Multi-language

Three independent layers, all native:

  • Fixed theme strings embrulhe em t('Read more')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 the templates nothing changes: field(), the_title() e option() already resolve the active language with a fallback to the default, so you 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 &lt;comando&gt;./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 &lt;type&gt;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 validate without saving
  • --json-stdin --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 &amp; 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 the .env: o sitemap e o absolute_url() depend on it.

Upgrades

The engine is a versioned dependency, shipped 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 to the latest version.

The full reference lives in the skill

Install the official skill and your AI starts consulting this whole reference (schema, helpers, CLI and deploy flows) without leaving the chat.