Skip to content
Developer docs

AI context for Basehim development

Updated 19 min read

This page is written for AI assistants. Give it to your AI before asking it to build a Basehim app or theme, and it will follow Basehim's rules and real APIs instead of guessing from other CMSs. It is up to date with Basehim 1.2.87.

How to use it

  1. Paste it. Copy the whole block below into your AI chat as the first message, or into your coding agent's project instructions (for example a CLAUDE.md or rules file in your project).
  2. Or link it. Assistants that can read web pages can be told: "Read https://www.basehim.com/docs/ai-context and follow it for everything you write."
  3. Then ask for what you want. Ready-made requests are in Prompts for building with AI, and the whole workflow is in Build with AI.

The context is deliberately short. It names the rules and the exact functions, and points to the full documentation for detail. When your AI isn't sure about something, it is told to say so and check the docs rather than invent an API.

The context

BASEHIM DEVELOPMENT CONTEXT  (for AI assistants · Basehim 1.2.87 · https://www.basehim.com/docs/ai-context)

You are helping someone build for Basehim, an open-source PHP CMS. Follow this
context exactly. It overrides habits from WordPress, Laravel or other systems.

== 0. HOW TO WORK ==
- Use ONLY the APIs named here or in the Basehim docs (https://www.basehim.com/docs).
  Never invent a function, hook, option or class. If you are not sure one exists,
  say so and point the person to the doc page to check.
- Ask at most 2-3 questions if the request is unclear (who uses it, what it
  stores, public or admin). Otherwise state your assumptions and start.
- Deliver complete files, never fragments with "...". Show the folder tree first.
- Finish with: how to install it, how to test it, and the CHECKLIST (section 12)
  with each line confirmed or flagged.
- Target environment: PHP >= 8.1 and MySQL/MariaDB, deployed by uploading files,
  often on hosting without shell access. Core has no third-party runtime
  dependencies. Do not require Composer packages, npm, a build step or shell
  commands; bundle any library the app or theme needs.
- Never edit core files. Everything lives in content/apps/{slug}/ or
  content/themes/{slug}/.

== 1. APP OR THEME? ==
- THEME = presentation only: templates, CSS, JS, Customizer options, menus,
  widget areas, and filters from an optional boot.php. A theme cannot register
  routes, AJAX actions or database tables.
- APP = behaviour: admin screens, routes, AJAX, tables, scheduled tasks, hooks,
  content types, dashboard widgets, MCP tools. Needs routes/data? It's an app
  (a theme may ship alongside one).

== 2. APP STRUCTURE ==
content/apps/{slug}/
  app.json            manifest (required)
  src/App.php         entry class, extends \App\Core\App
  src/...             your other classes (services, controllers)
  views/              PHP templates
  assets/             css, js, images, icon.png  <- the ONLY public folder

app.json:
  {
    "name": "Event Manager",
    "slug": "event-manager",                 // = folder name; a-z 0-9 - _ ; permanent
    "version": "1.0.0",                      // semver; bump every release
    "description": "One or two plain sentences for the site owner.",
    "namespace": "YourCompany\\EventManager", // maps to src/; NEVER "App\\"
    "icon": "assets/icon.png",               // square PNG/GIF 256 or 512 px, <= 1 MB
    "developer": { "name": "...", "url": "https://..." },
    "permissions": ["posts.read", "schedule", "db.raw"],
    "permission_reasons": { "db.raw": "Keeps attendee lists in its own tables." },
    "requires": { "php": ">=8.1", "basehim": ">=1.2.87" }
  }
Optional: "entry" (class name, default App), "src" (default src),
"requires.apps": { "other-app": ">=2.0" }. (Comments above are explanations;
real JSON has no comments.)

== 3. ENTRY CLASS AND LIFECYCLE ==
    <?php
    declare(strict_types=1);
    namespace YourCompany\EventManager;

    class App extends \App\Core\App
    {
        public function boot(): void { /* registrations only; runs on EVERY request */ }
        public function onInstall(): void {}                         // defaults
        public function onActivate(): void {}                        // CREATE TABLE IF NOT EXISTS; runs every activation
        public function onDeactivate(): void {}                      // keep data
        public function onUpgrade(string $from, string $to): void {} // migrations; also fires on downgrade
        public function onUninstall(): void {}                       // drop own tables, dropAllSettings(), delete own files
    }
- boot(): register hooks, routes, AJAX actions, tasks, widgets, menus, MCP tools.
  NO database queries, HTTP calls, file I/O or mail in boot().
- A boot() that throws switches the app off. Catch only what you expect to fail.
- onUpgrade(): one version_compare($from, 'x.y.z', '<') block per version; check
  before changing (column exists?) so it can run twice. A throw stops the app.
- Class files must have no code that runs on include (no echo, no top-level calls).

== 4. HARD RULES ==
 1. NEVER declare a method on your App class with a base-class name (section 5).
    PHP refuses to load the class. Use specific names or your own classes.
 2. Browser JSON calls -> $this->ajax() actions. Never hand-write a JSON route.
 3. Public pages/endpoints -> $this->router()->site(). Admin pages -> adminGet()/adminPost().
    Never the old $this->get()/$this->post() (no security checks).
 4. Core content -> $this->api(). Never raw SQL INSERT/UPDATE on core tables.
 5. SQL only with bound parameters. Tables via $this->table('name').
 6. Escape all output: htmlspecialchars($v, ENT_QUOTES). In JS use textContent.
 7. Check capability AND record ownership in every handler that changes data.
 8. Files: private files in $this->appStorageDir('x'); uploads via AJAX 'files'
    and UploadedFile::moveTo(); media paths via $this->api()->media()->path($id).
    Never build a path from user input. Never execute uploaded content.
 9. Declare ALL permissions you use, or none. Declaring one enforces all.
10. Never store user passwords, session IDs or tokens. Never log secrets.
11. Never read .env, never `new PDO`, `eval`, shell functions, or disable TLS checks.
12. Don't call your own site over HTTP; use $this->api().
13. Asset URLs via $this->asset('css/app.css').
14. Clean up everything you created in onUninstall().
15. Hook callbacks receive ONE argument unless you pass the count:
    addFilter($tag, $fn, 10, 2) / addAction($tag, $fn, 10, 2). A callback that
    needs more arguments without it fails silently (the error is only logged).

== 5. RESERVED METHOD NAMES (base class \App\Core\App) ==
Do not declare these on your App class: get post route routeGroup adminGet
adminPost addAction addFilter doAction applyFilters ajax useAjaxClient router
addAdminMenu addAdminStyle addAdminScript adminView addHead addFooter
enqueueStyle enqueueScript addEditorScript addEditorStyle addEditorConfig
registerBlockRenderer registerPostType registerWidget widgets api schedule
getSetting setSetting deleteSetting allSettings dropAllSettings settingGroup
settings table schema db view asset appStorageDir containerDir icon iconUrl
cache config request session log logger allowed make slug path manifest
permissions.
(Full list: tests/Unit/AppBaseClassTest.php in the Basehim repository.
When in doubt, prefix your helpers with your domain: eventTables(), renderCard().)

== 6. APP API CHEAT SHEET ==
HOOKS
  $this->addAction('post.created', [$this, 'onPostCreated']);              // 1 argument
  $this->addFilter('post.content', fn(string $html, array $post) => $html, 10, 2);  // 2 arguments
  A filter must return the value. Own hooks: '{slug}.something'.
  List: https://www.basehim.com/docs/app-hooks

ADMIN SCREENS  (require access_app:{slug}; admins have it)
  $this->addAdminMenu(['url' => '/admin/events', 'label' => 'Events', 'icon' => 'calendar']);
  $this->adminGet('/events', [$this, 'screen']);            // -> /admin/events
  $this->adminPost('/events/save', [$this, 'save']);        // verify CSRF yourself:
    $this->session()->verifyCsrf((string) $request->input('_csrf'))
  return $this->adminView('screen', 'Page title', ['rows' => $rows]);  // views/screen.php
  Widen access with the 'admin.area_policy' filter: ['/admin/events/mine' => 'read'].

AJAX  (POST /ajax; checks session, origin, CSRF, capability, area, rate, input)
  $this->ajax('save-note', [                    // registers "{slug}.save-note"; boot() only
    'handler'    => [$this, 'saveNote'],
    'capability' => 'edit_posts',               // required; 'read' = any signed-in user
    'area'       => '/admin/events',            // never more open than that screen
    'input'      => ['post_id' => 'required|int|min:1', 'note' => 'required|string|max:500'],
    'rate_limit' => ['max' => 30, 'per' => 60], // default 120/min per user
    // 'methods' => ['GET'] (reads only), 'auth' => 'public', 'idempotent' => true,
    // 'files' => ['file' => ['types' => ['zip'], 'max_size' => 10485760, 'mimes' => ['application/zip']]]
  ]);
  public function saveNote(\App\Core\Ajax\AjaxContext $ctx): array {
      $id = $ctx->input('post_id');   // validated; also all(), has(), user(), userId(),
                                      // can(), authorize(), file('x'), log(), requestId()
      throw \App\Core\Ajax\AjaxException::notFound();     // also forbidden($msg),
                                                          // invalid($msg, $fields), conflict($msg)
      return ['saved' => true];       // never echo
  }
  Input types: string int number bool array(items) object(fields); modifiers:
  required nullable min max in:a,b email url; regex via array form
  ['type' => 'string', 'regex' => '/^[a-z0-9-]+$/']. Undeclared fields are refused.
  JS: Basehim.ajax.request('{slug}.save-note', {...}).then(d => ...).catch(err => bhToast(err.message, 'error'));
      Basehim.ajax.get(...), Basehim.ajax.upload(action, data, {file: f}, {onProgress})
  On the public site the client must be loaded: bh_ajax_client() (theme) / $this->useAjaxClient() (app).

PUBLIC ROUTES
  $site = $this->router()->site();
  $site->get('/events/{slug}', [EventController::class, 'show'],
      ['name' => 'events.show', 'where' => ['slug' => '[a-z0-9-]+']]);
  $site->post('/api/event-manager/rsvp', [$this, 'rsvp'], ['auth' => true, 'rate' => [10, 60], 'idempotent' => true]);
  Options: name where auth can csrf(on by default for changes) rate idempotent middleware replace.
  Handler parameters: (\App\Core\Request $request, string $slug) - route params follow the request.
  Render in the theme: \App\Core\Http\SitePage::view($this, 'event', $data, ['title' => $t]);
  JSON: \App\Core\Response::json($data, 200). URL: $this->router()->url('events.show', ['slug' => $s]).
  Reserved for core: /ajax /api/v1 /mcp /oauth /.well-known /uploads /content /storage.
  Webhooks: 'csrf' => false + \App\Core\Http\Webhook::verifyHmac()/verifyTimestamped(),
  and $this->api()->idempotency()->once($scope, $eventId, fn() => ...).

CORE API  $this->api()->...  (never throws; returns null/false/0/[] and logs the reason)
  posts() pages() content('type') media() users() comments() terms() menus()
  settings() cache() mail() schedule() http() transaction(fn) idempotency()
  Each content resource: find($id) all($filters, $limit) paginate($f, $page, $per)
  create($data) update($id, $data) delete($id). all() is capped at 500.
  cache()->remember('key', 3600, fn() => ...)       // namespaced to the app
  http()->timeout(5)->withToken($k)->getJson($url) / postJson($url, $data)
  mail()->send($to, $subject, $html) / sendTemplate($to, $subject, $heading, $html)
  terms()->firstOrCreate('tag', 'Name')
  settings()->updateOwn('counter', fn($n) => (int) $n + 1, 0)   // race-safe own setting
  transaction(function () { ... });   // no mail or HTTP inside (it may retry)

SCHEDULED TASKS  (register in boot(); run after page views unless real cron is set)
  $this->api()->schedule()->hourly('sync', [$this, 'sync']);   // also daily(), everyMinutes(15, ...)
  Work in small batches with a saved cursor; must be safe to re-run.

SETTINGS (own, no permission needed, removed on uninstall)
  $this->getSetting('key', $default)  $this->setSetting('key', $value)
  $this->deleteSetting('key')  $this->allSettings()  $this->dropAllSettings()
  Large/frequent values: $this->api()->settings()->set('app:' . $this->slug(), 'key', $v, false)

DATABASE  (needs "db.raw" if the app declares permissions)
  $this->schema("CREATE TABLE IF NOT EXISTS `{$this->table('items')}` (... ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  $this->db()->select($sql, ['id' => $id])  selectOne()  execute()
  $this->db()->insert($this->table('items'), $row)  update($table, $data, $where)  delete($table, $where)
  Core tables inside SQL: {posts} {users} {post_meta} ...  Times: bh_now() (UTC), bh_to_utc().

VIEWS, ASSETS, LOGS
  $this->view('name', $data)  -> views/name.php with $app and $base available
  $this->asset('css/app.css')  $this->addHead(fn() => '<link ...>')  $this->addFooter(fn() => '<script ...>')
  $this->addAdminStyle(...) / addAdminScript(...) for admin assets
  $this->log('message', ['ctx' => 1], 'warning')  -> Apps -> Logs

CONTENT TYPES
  $this->registerPostType('event', ['label' => 'Events', 'singular' => 'Event', 'icon' => 'calendar',
      'supports' => ['title', 'content', 'thumbnail'], 'taxonomies' => ['category']]);
  Then $this->api()->content('event').

DASHBOARD WIDGETS
  $this->registerWidget('orders', ['surfaces' => ['dashboard'], 'title' => 'Orders today',
      'icon' => 'shopping-cart', 'capability' => 'access_app:' . $this->slug(),
      'dashboard' => ['view' => 'stat', 'size' => 'third',
          'data' => fn(\App\Services\Dashboard\WidgetContext $ctx) => ['items' => [['label' => 'Orders', 'value' => 12]]]]]);
  Views: stat list table chart feed status html. Expected states:
  throw new \App\Services\Dashboard\WidgetUnavailable('Not connected yet').

DATA TABLE COLUMNS (Posts, Pages, Comments, Apps lists)
  $this->addFilter('datatable.columns.content.posts', function (array $cols): array {
      $cols[] = ['id' => '{slug}.score', 'label' => 'Score', 'prepare' => [$this, 'bulkLoad'],
                 'render' => [self::class, 'cell'], 'default_visible' => false, 'capability' => 'edit_posts'];
      return $cols; });
  prepare(array $rows): ONE bulk query. render(array $row, $prepared): string - pure, escaped.

MEDIA (1.2.87)
  $this->api()->media()->fromUpload($ctx->file('file'), ['title' => 'Logo'])   // media.write
  ->replaceFile($id, $path, ['expect_sha1' => $sha1])  ->regenerate($id)  ->path($id)
  ->sizes()  ->uploadRules($context)  ->lastError()
  Filters: media.upload.allowed_types media.upload.max_bytes media.upload.check
  media.upload.file media.upload.filename media.upload.directory media.upload.row
  media.sizes media.item. Actions: media.uploaded media.updated media.file_replaced
  media.sizes_generated media.deleting media.deleted. (Pass the argument count:
  e.g. media.upload.check gets ($veto, $info, $ctx) -> addFilter(..., 10, 3).)

MCP TOOLS FOR AI AGENTS
  $this->addFilter('mcp.tools', function (array $tools, array $scopes): array {
      $tools[] = ['name' => '{slug_underscored}_list', 'description' => 'What it does. Read-only.',
                  'inputSchema' => ['type' => 'object', 'properties' => [...], 'additionalProperties' => false],
                  '_scope' => 'posts:read'];
      return $tools; }, 10, 2);
  $this->addFilter('mcp.call.{name}', fn($result, array $args) => $this->service()->list($args), 10, 2);
  Validate $args yourself. Core tool names win collisions. Arrays are returned as JSON.

== 7. PERMISSIONS (app.json "permissions") ==
posts.read posts.write posts.delete pages.read pages.write pages.delete
media.read media.write media.delete users.read users.write users.delete
comments.read comments.write comments.moderate terms.read terms.write
menus.read menus.write settings.read settings.write mail.send http.outbound
schedule db.raw agents.
- A write implies its read. Own settings, cache and log need nothing.
- Missing permission: api() calls return empty and log; db()/schema() throw
  AppPermissionMissing (core shows the permission window - don't catch it).
- Degrade: if ($this->allowed('mail.send')) { ... }
- Give a plain reason for each in "permission_reasons" (<= 200 chars).

== 8. ADMIN UI (use these; no custom CSS frameworks) ==
Buttons: bh-btn + bh-btn--primary (one per screen) | --secondary | --danger | --ghost ; --sm --lg --icon
Cards: bh-card, bh-card__head, bh-card__title, bh-card__body, bh-card--pad
Fields: bh-label (for=), bh-input, bh-select, bh-textarea, bh-help; aria-invalid="true"
Switch: <span class="bh-switch"><input type="hidden" name="x" value="0"><input type="checkbox"
        role="switch" class="bh-switch__input" id="x" name="x" value="1"><span class="bh-switch__track" aria-hidden="true"></span></span>
Badges: bh-badge bh-badge--green|gray|blue|amber|red|violet|indigo (green=success, amber=warning,
        red=danger, blue=info: the same in every accent)
Palette (1.3.0): bh-text-bg-NAME, bh-bg-NAME, bh-bg-NAME-subtle, bh-text-NAME, bh-border-NAME, where NAME is
        red|orange|yellow|green|blue|pink|purple|brown|black|white|success|warning|danger|info
Light/dark and accent (1.3.0): never hard-code #fff, white or hex in app CSS. Use core classes, or
        rgb(var(--bh-surface | --bh-text | --bh-muted | --bh-line | --bh-accent | --bh-on-accent | --bh-success)),
        or a Tailwind build with presets: [require('../../../tailwind.preset.js')], corePlugins: { preflight: false }.
        Charts: Basehim.appearance.get().palette, redraw on Basehim.appearance.onChange(fn).
Stylesheet for one screen, from its view: <?= \App\Core\Http\HeadStyles::add($this->asset('x.css')) ?>
        (printed in <head>). Never a <link> in the body, never $this->style() in shared partials.
Empty: bh-empty, bh-empty__icon, bh-empty__title, bh-empty__text, bh-empty__actions
JS: await bhConfirm(msg, {danger: true}) ; bhToast(msg, 'success'|'error'|'warning'|'info') ;
    bhBusy(button, true, 'Saving...') / bhBusy(button, false) ; bhLoader(el, 'Saving...').done('Saved')
Icons in PHP views: icon('heroicon-name', 'w-4 h-4'). Never use alert()/confirm().
Sentence case. Name buttons by action ("Save changes", "Delete").

== 9. SECURITY ==
- Validate every input (AJAX schema, or by hand in routes). URLs: http/https only.
- Escape every output. Valid input can still contain <script>.
- Capability on every route/action/widget + ownership check before changing a record.
- CSRF: automatic in ajax() and site(); manual in adminPost(). GET never changes data.
- Rate-limit public and expensive actions.
- Uploads: narrowest 'types', real 'max_size', 'mimes'; store with moveTo() under a name you choose.
- Outbound HTTP: short timeout, TLS on, be careful with user-supplied URLs.
- Third-party API keys: in own settings, masked in the UI, never in logs/URLs/responses.
- An app is not a sandbox: write code a site owner can trust.

== 10. THEMES ==
content/themes/{slug}/
  theme.json
  templates/ index.php single.php page.php archive.php search.php 404.php   (ship all six;
             a missing template falls back to 404.php) and templates/partials/
  assets/    css, js, images
  widgets.php (optional, RETURNS an array)   boot.php (optional, filters only; $app and $hooks in scope)

theme.json:
  { "name": "My Theme", "slug": "my-theme", "version": "1.0.0", "author": "You",
    "description": "...", "requires": { "basehim": ">=1.2.45" },
    "menu_locations": { "primary": "Main menu", "footer": "Footer" },
    "widget_areas": { "sidebar": { "name": "Sidebar", "before_widget": "<section id=\"%1$s\" class=\"widget %2$s\">",
        "after_widget": "</section>", "before_title": "<h2 class=\"widget__title\">", "after_title": "</h2>" } },
    "customizer": { "brand": { "label": "Brand", "options": {
        "accent": { "type": "color", "label": "Accent", "default": "#2563eb" } } } } }
  Option types: text textarea color select toggle number range image url font.
  Colour/number/range/font options become CSS variables: accent -> --bh-accent.
  Use them with a fallback: color: var(--bh-accent, #2563eb). Others: bh_theme_option('key', $default).

Templates receive: $base $site_title $tagline $logo_url $favicon_url $footer_text
  $primary_menu $footer_menu $seo $current_url. Lists (index/archive/search): $posts,
  $meta ['page','per_page','total','last_page'] (there is NO $pagination), $query on search.
  single/page: $post with 'content' already rendered to HTML (echo it, don't escape it).
Partials: $partial('header'); $partial('card', ['post' => $p]);  (partials can't include partials)
Pagination links use ?page=N (never /page/2), keeping other query params.

Head and footer (BOTH required, or apps and analytics get nothing):
    <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
      <title><?= htmlspecialchars($seo['title'] ?? $site_title) ?></title>
      <link rel="stylesheet" href="<?= theme_asset('theme.css') ?>?v=<?= urlencode(BASEHIM_VERSION) ?>">
      <?= bh_head() ?></head>
    <body class="<?= bh_body_class('my-theme') ?>"> ... <?= bh_footer() ?></body>
Core prints ALL SEO/social tags (description, canonical, robots, og:*, twitter:*, JSON-LD).
A theme prints none of them.

Theme helpers: bh_date($t, $fmt) bh_time() bh_datetime() bh_time_ago() bh_time_tag()
  (stored times are UTC: NEVER date(..., strtotime(...))) ; menu_html($primary_menu, ['class' => 'menu'])
  menu_at('location') menu_assets() ; widget_area('sidebar') has_widget_area('sidebar') ;
  bh_post_image($post, 'medium', ['class' => '...']) bh_image_url($post, 'thumbnail') ;
  bh_comment_form($post, $args) bh_comment_count($post) bh_author_box($post, $args) bh_author_url($post) ;
  link_to($path) theme_asset($rel) icon($name, $class) bh_setting($group, $key, $default) bh_is_preview().
Escape every value you print except $post['content'].

== 11. VERSIONS ==
- Set requires.basehim to the lowest version that has every API you use
  (media fromUpload/replaceFile/appStorageDir/AJAX files: >=1.2.87).
- Bump "version" on every release; migrate in onUpgrade(); write release notes for site owners.

== 12. CHECKLIST (end every delivery with this, each line confirmed or flagged) ==
[ ] slug = folder; namespace is not App\; version and requires correct
[ ] no App method uses a reserved base-class name
[ ] boot() only registers; class files have no side effects
[ ] hook callbacks with more than one parameter pass the argument count
[ ] every permission used is declared with a reason; nothing extra; works if one is refused
[ ] every route/action/widget has a capability; handlers check ownership
[ ] CSRF covered (ajax()/site() or manual in adminPost()); GET changes nothing
[ ] all input validated; all output escaped
[ ] no paths from user input; private files in appStorageDir(); uploads via 'files'
[ ] no secrets stored/logged; outbound HTTP has timeouts
[ ] tables via $this->table(), indexed, created idempotently; onUpgrade() re-runnable
[ ] onUninstall() removes tables, settings and private files
[ ] admin UI uses bh-* components and bhConfirm/bhToast
[ ] (themes) all six templates; bh_head() and bh_footer(); no SEO tags; bh_date() for dates
[ ] tested: install from ZIP, Apps -> Report -> Check again, activate, use, uninstall

DOCS: apps https://www.basehim.com/docs/apps · best practices /docs/app-best-practices ·
API /docs/app-api · hooks /docs/app-hooks · AJAX /docs/ajax-actions · routes /docs/public-routes ·
permissions /docs/app-permissions · admin UI /docs/app-admin-screens · widgets /docs/dashboard-widgets ·
tables /docs/data-tables · media /docs/media-extensions · themes /docs/themes ·
theme reference /docs/theme-reference · failures /docs/app-fault-isolation

Notes for people

  • The AI still makes mistakes. This context prevents the common ones, but it doesn't replace testing. Install what it writes on a test site first, and run the review prompt on the result.
  • Never paste secrets (your .env, database passwords, API keys) into an AI chat. Nothing in this workflow needs them.
  • Keep it current. This page is updated with Basehim releases; if you saved a copy, refresh it when you update Basehim.
  • The full rules and their reasons are in App development best practices.

Discussion

Leave a comment