Developer docs
Start
Build with AI
Themes
Apps
- App development
- App development best practices
- Hooks and filters
- The App API
- Sending email from apps
- Admin bar for apps and themes
- Public routes for apps
- Admin screens, routes and UI components
- Dashboard widgets
- Data tables
- AJAX actions
- Media and uploads for apps
- App permissions
- Content types, blocks and widgets
- Content types, taxonomies and custom fields
- Search
- When an app fails: fault isolation and recovery
API and AI agents
Security and contributing
Contributing and running the tests
Basehim is a deliberately small, dependency-free PHP CMS, and contributions that keep it that way are welcome. This page gets you from a fresh clone to a passing test run and a pull request.
Before you start
- Bugs and small fixes: open a pull request directly.
- New features, dependencies or schema changes: open an issue first, so the shape can be agreed before you spend time on it.
- Security problems: report them privately, never in a public issue. See Security for developers.
Set up a checkout
git clone https://github.com/<your-username>/basehim.git
cd basehim
php -S localhost:8000 server.php # open http://localhost:8000 and run the installer
Set APP_DEBUG=true in the generated .env while you work. To start over, drop the database and delete .env.
The test suite
The runner and its assertions are a few hundred lines in tests/support/. The suite uses this runner instead of PHPUnit, so it needs only PHP.
php tests/run.php # unit tests: no database needed
php tests/run.php --filter=csrf # only tests whose name contains "csrf"
php tests/run.php --keep # keep the test database and logs afterwards
Integration tests
Integration tests drive the real index.php over HTTP, with real sessions, the real login form and a real database. They run when you point the runner at a database server:
BASEHIM_TEST_DB_HOST=127.0.0.1 \
BASEHIM_TEST_DB_USER=root BASEHIM_TEST_DB_PASS=secret \
php tests/run.php
| Variable | Default | |
|---|---|---|
BASEHIM_TEST_DB_HOST | none | Required to enable integration tests |
BASEHIM_TEST_DB_PORT | 3306 | |
BASEHIM_TEST_DB_USER, BASEHIM_TEST_DB_PASS | root, empty | |
BASEHIM_TEST_DB_NAME | basehim_test | Must end in _test: it is dropped and recreated |
BASEHIM_TEST_DB_PREFIX | bht_ | Exercises table prefixing |
A run creates the database, applies every migration, seeds a user for each role, installs a test app, and serves the site with PHP's built-in server. It never reads your own .env or touches your own database. With Node.js 18+ installed, the JavaScript client tests run too. The fault-isolation and public-route tests install their own broken and example apps under throwaway slugs and remove them afterwards.
CI runs the whole suite against MySQL 8.0 on PHP 8.1 and 8.3, on every push and pull request.
Writing a test
<?php
// tests/Integration/MediaOwnershipTest.php
test("an author cannot rename someone else's image", function () {
$b = bh_browser('author'); // signed in through the real login form
$res = $b->ajax('media.update', ['id' => 1, 'title' => 'x']);
assert_same(403, $res->status, $res->body);
assert_same('FORBIDDEN', $res->errorCode());
});
Test files end in Test.php and are found automatically.
- Tests in
tests/Unit/must not need a database. - Tests in
tests/Integration/may usebh_browser($role),bh_db(),bh_env(),bh_core_log()andbh_app_log(). - Assertions:
assert_same,assert_true,assert_false,assert_contains,assert_not_containsandassert_throws. Each takes an optional message. - Name a test after what it proves, in a sentence. A failing test should read like a bug report.
Add a test for every bug you fix, and check that it fails without your fix.
Code conventions
declare(strict_types=1);at the top of every PHP file.- PSR-12, four-space indentation, LF line endings.
- PSR-4:
App\maps toapp/, and the file name matches the class. - Classes are
finalunless they are designed for extension. - Repositories do data access, services do business logic, controllers stay thin.
- A short docblock on every class, and on any method whose behaviour isn't obvious. Explain why, not what.
- PHP 8.1 is the minimum. Don't use syntax from later versions.
Check syntax before pushing:
find . -name "*.php" -not -path "./vendor/*" -print0 | xargs -0 -n1 php -l
Database changes
Add a new numbered file to database/migrations/. Never edit one that has shipped, because existing sites have already run it.
- Use
{table}tokens soDB_PREFIXworks. - Make every statement work on MySQL 5.7+ and MariaDB 10.3+.
- For conditional changes, check
information_schemaand usePREPARE/EXECUTEinstead of MariaDB-only syntax such asADD COLUMN IF NOT EXISTS.
See Updates, migrations and backups.
Compatibility
Basehim supports managed hosting without shell access, and that shapes what can be accepted:
- no third-party runtime dependencies, build steps, background processes or shell access at runtime. Where core can use the PHP command line (the app preflight check), it must work without it too;
- no new PHP extensions beyond the ones the installer checks;
- public behaviour (REST responses, hooks,
Appmethods, theme variables, URLs) changes only with a compatibility plan and a changelog entry; - no new public or protected methods on
App\Core\App. Apps extend it, so a name an existing app already uses for its own method, with another signature or visibility, is a fatal error that takes that app down on update. New app features go behind an existing accessor, as$this->router()->site()does.tests/Unit/AppBaseClassTest.phpfails if the base class grows.
Pull requests
- Branch from
main:git checkout -b fix/short-description. - Keep the change to one concern.
- Run
php tests/run.phpwith a test database, and add tests. - Update the docs if behaviour changed.
- Write a commit message that explains the reasoning, in a loose conventional style:
fix(router): resolve subdirectory base path on HEAD requests
feat(api): add cursor pagination to /api/v1/posts
docs(themes): document the widgets.php field format
Leave a comment