Section 1 mirrors .cursor/rules/karpathy-guidelines.mdc. The PHP standards in
section 3 have been re-derived from the actual code in src/ and from the
custom PHPCS sniffs in phpcs/Framework/Sniffs/, not copied from
.cursor/rules/php-standards.mdc — that file is stale (it still tells you to
read the version from kirki-ecommerce.php, a different project, and it omits
the file-header and ABSPATH rules this repo actually enforces). The sniffs are
the authority: if a rule here and a sniff disagree, the sniff wins and this file
should be corrected.
themeum/framework — a standalone PHP framework for WordPress plugins,
distributed as a Composer library and consumed by plugins (Kirki, Kirki
Ecommerce, GrowFund). It is the upstream package, not an application.
Two consequences that shape everything below:
- It has no application of its own. The public API surface is what consuming plugins call, so backwards compatibility matters more than it would in a plugin. Prefer additive changes.
- It is scoped on release. Consumers run
php-scoperover this tree into a prefixedThemeum\Framework\namespace. Avoid anything that breaks under prefixing: no dynamic class-name string building, no\Framework\...literals in strings where an FQCN constant would do.
example/ is a local WordPress playground plugin used to exercise the library
end to end. It is not library code — don't apply the library's conventions
to it reflexively, and don't ship changes there as if they were the feature.
Source: .cursor/rules/karpathy-guidelines.mdc
Behavioral guidelines to reduce common LLM coding mistakes.
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
Don't assume. Don't hide confusion. Surface tradeoffs.
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
Touch only what you must. Clean up only your own mess.
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it — don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: every changed line should trace directly to the user's request.
Define success criteria. Loop until verified.
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
When entering plan mode in this project, always use the OpenSpec workflow
instead of writing a freeform plan. Reach for the openspec-* / opsx:*
skills:
opsx:explore— think through the problem before committing to a changeopsx:propose— generate a full proposal (spec deltas, design, tasks)opsx:apply— implement tasks from an existing changeopsx:sync— sync delta specs into main specsopsx:archive— finalize and archive a completed change
Before implementing tasks from an existing change, always ask me to run
opsx:apply manually by myself instead of applying automatically.
Artifacts live in openspec/changes/<change-id>/. Note that openspec/ is
gitignored — it never shows up in git status, so don't conclude from a
clean status that no proposal exists. Check the directory.
The two commands that gate every change:
composer phpcs && composer test:unitAlways run these through Composer, never the bare binaries. Both wrappers exist for a reason:
composer test:unitadds--prepend tests/prepend.php, which definesABSPATHbefore anything loads. A barevendor/bin/phpunitproduces no useful output.composer phpcsrunsscripts/run-phpcs.phpwith-d auto_prepend_file=and injects--standard=phpcs.xml.dist.
Other commands:
| Command | What it does |
|---|---|
composer test |
Full suite (currently the same as test:unit) |
composer test:unit -- --filter 'SomeTest' |
Narrow to one test class |
composer phpcbf |
Auto-fix what PHPCS can fix |
composer phpcs:fix |
The project's wider fixer script |
make up / make init |
Bring up the Docker WordPress playground |
make test:unit |
Run the suite inside the php container |
make wp CMD="plugin list" |
WP-CLI inside the container |
phpstan.neon.dist sets level 5 over src/, but phpstan is not in
require-dev — don't install it unasked; say so and let me decide.
Do not use browser-preview / dev-server tooling to verify anything here. There
is no frontend and no dev server. Static checks plus the unit suite are the
verification; anything needing a real WordPress request goes through the Docker
playground and curl, and if a change genuinely needs a human to look at it,
say so and let me check.
Target PHP 7.4 (composer.json config.platform.php). PSR-12 plus the
custom Framework standard, enforced by phpcs.xml.dist.
PHPCS scans src/ only — with src/helpers.php, src/Polyfill/, and
src/Console/stubs/ excluded. tests/ and example/ are unchecked, which
means style there is a matter of matching neighbours, not of a passing build.
Don't take unchecked code as licence to diverge, but do note that tests/
legitimately uses a lighter style (see section 5).
Framework.Files.FileDeclarationOrder and Framework.Commenting.FileDocblock
require exactly this shape in any file declaring a class, interface, or trait:
<?php
/**
* One or two sentences on what this file is and why it exists.
* Say what is non-obvious — the design constraint, the reason it is separate
* from its neighbour — not what the class name already says.
*
* @package Framework
* @subpackage Cache
* @since 1.0.0
*/
namespace Framework\Cache;
defined('ABSPATH') || exit;
use Closure;
use Framework\Contracts\Store;
use InvalidArgumentException;
use function Framework\config;- The file docblock comes before
namespace, and must have a description,@package, and@since. @subpackageis required when the namespace is more than two segments deep (Framework\Cacheneeds none;Framework\Cache\Storesdoes).- Tag values are aligned in the file docblock (
@package Framework). defined('ABSPATH') || exit;comes immediately after the namespace. Missing it is a PHPCS error.use functionimports go in their own group after the class imports.
- Class names: PascalCase. One class per file, filename matches, PSR-4
(
Framework\→src/,Framework\Tests\→tests/). - Never
final— zero occurrences insrc/, and consumers subclass freely. - Interfaces live in
Framework\Contracts(src/Contracts/). - Traits live in
Framework\ConcernsorFramework\Supports\Traits. - Facades live in
Framework\Supports\Facades. - Exceptions live in
Framework\Exceptions.
- Methods and variables: snake_case (
get_cart,$customer_id), enforced byFramework.NamingConventions.SnakeCaseMethod/SnakeCaseVariable. The only exceptions are methods a native PHP interface mandates (ArrayAccess::offsetGet,JsonSerializable::jsonSerialize, …) and PHPUnit's ownsetUp/tearDown— keep those camelCase. - Class constants: SCREAMING_SNAKE_CASE, enforced by
ScreamingSnakeCaseConstant. privateis forbidden, enforced byFramework.Visibility.NoPrivate. Useprotectedfor internals,publicfor the API surface consuming plugins touch. There are zeroprivatemembers insrc/.- Names must express intent; no
$a,$b,$temp.
Use static:: — 342 occurrences against 15 for self::, and every one of those
15 is somewhere PHP forbids static:: on 7.4: a property default, a default
parameter value, or a constant expression.
// ✅ everywhere else
return static::PAGINATION_LIMIT;
// ✅ the only legitimate self::, because these are constant expressions
protected $match_using = self::MATCH_PATH;
public function for_page($page, $per_page = self::PAGINATION_LIMIT)RequiredMethodDocblock, RequiredPropertyDocblock, DocblockTagOrder, and
DocCommentMissingShort together mean: a description is mandatory, @param is
mandatory when the method takes parameters, @return is mandatory always
(@return void included, constructors included), and tags must appear in the
order @template, @param, @return, @throws, @since.
Separate tag groups with a blank line — that's the dominant style by a wide margin (2513 to 6):
/**
* Attempt to acquire the lock without waiting.
*
* A losing insert is not the end of the attempt: the row may belong to a caller
* that died, so an expired row is removed with a compare and delete.
*
* @param string $payload The value to write.
*
* @return bool
*
* @throws \Framework\Exceptions\QueryException When the statement fails.
*
* @since 1.0.0
*/
protected function insert(string $payload)/**
* The value written for this caller's hold, kept so release can match on it.
*
* @var string|null
*
* @since 1.0.0
*/
protected $payload;{@inheritDoc}short-circuits the sniff — use it for genuine overrides.@since: the codebase is overwhelmingly1.0.0, with a handful of later additions carrying the release they landed in (2.1.2insrc/View/). Match the surrounding file; default to1.0.0. If you're adding a genuinely new public API and think it should carry the upcoming release version, ask.- Omit
@throwsunless the method really throws.
- Short array syntax
[], neverarray(). - No
declare(strict_types=1)— zero occurrences; don't introduce it. - No Yoda conditions. Write
if ($count === 0), notif (0 === $count). Nothing in PHPCS enforces either style and the codebase uses natural order throughout. - Prefer early returns.
- Comments are for why, not what. Docblocks carry the intent; inline
//comments are rare insrc/and should stay rare. Don't narrate the code.
Used deliberately, not universally — roughly 64 return types and 100 typed properties across ~2700 functions. Treat them as encouraged for new code and match whatever the surrounding class already does; don't retrofit types onto a file that has none.
PHP 7.4 syntax only: no union types, no constructor property promotion, no
enums, no match, no named arguments, no nullsafe operator, no str_contains
(use the Framework\Polyfill helpers where they exist).
Global helpers are namespaced functions in src/helpers.php, imported
explicitly rather than called globally:
use function Framework\app;
use function Framework\config;When a manager gains a public method, add the matching @method static line to
its facade in src/Supports/Facades/ — the facade docblock is the IDE contract
and it goes stale silently otherwise.
- Escape, sanitize, and internationalize per WordPress conventions at the boundaries.
- Prefer the framework's own seams over raw WordPress globals. In particular,
database access goes through the
DBfacade /Database\Connection\Connection, notglobal $wpdb— going through the facade keeps the query log and the error handling. Raw SQL is fine where the reason is real (the options cache would defeat an atomic lock, for instance); reaching pastDB::is not.
| Path | Contents |
|---|---|
src/Contracts/ |
Interfaces |
src/Concerns/, src/Supports/Traits/ |
Traits |
src/Supports/Facades/ |
Static facade proxies |
src/Exceptions/ |
Exception classes |
src/Middlewares/ |
HTTP middleware |
src/<Subsystem>/ |
A cohesive subsystem (Cache/, Database/, Session/, RateLimiting/, View/, Http/) with its own ServiceProvider where it needs boot-time hooks |
tests/Unit/<Subsystem>/ |
Tests, mirroring the src/ layout |
tests/Support/ |
Test doubles and stubs, mirroring the same layout |
docs/ |
User-facing feature documentation |
example/ |
The playground plugin — not library code |
Put new code beside its peers rather than inventing a new top-level directory.
Never hand-edit: vendor/, example/vendor/, example/libraries/. The
last is a scoped copy of this very library, regenerated by make scope.
PHPUnit 9 with the Yoast polyfills, bootstrapped by tests/Unit/bootstrap.php.
There is no WordPress in the test process — tests/Support/StubsWordPressFunctions.php
stands in for the WordPress functions the library calls, and tests/Support/
holds hand-written doubles (TestWpdb, FrozenArrayStore, FrozenRateLimiter).
Conventions, all of which differ from src/ because PHPCS doesn't reach here:
- Test classes extend
Framework\Tests\Unit\TestCase, which resets the container,Strmacros, the wpdb double, route state, and the facade cache intearDown(). A subsystem usually adds its own base (CacheTestCase,RateLimiterTestCase) for shared setup. - Test methods:
test_snake_case_describing_the_behaviour(). - No docblocks, no ABSPATH guard, no file header in test files.
- Typed properties and
: voidreturn types are used freely here. - Time-dependent behaviour is tested through a frozen clock double
(
->freeze($now)/->travel($seconds)), neversleep().
When you add a WordPress function stub, add it to
StubsWordPressFunctions.php rather than defining it inline in a test.
Write the test that would have caught the bug. A change to src/ that adds
behaviour and no test is incomplete unless I said otherwise.
User-facing features get a docs/<feature>.md, structured like
docs/cache.md: a table of contents, then numbered ## N. Topic
sections, quick start first, configuration and drivers in the middle, and — for
anything modelled on a Laravel API — a "Where this differs from Laravel"
section near the end that is honest about the gaps. That section is not
optional; it is what stops a consumer assuming parity we don't have.
Keep docs in the same change as the code. A doc that describes the old behaviour is worse than no doc.
Don't commit or push unless I ask. When I do ask commit the changes with a inferred commit message that is good enough for PR title and description and also push on behalf of me.
When using the /grill-me skill ask me questions one by one and use the graphical interface so that I can select my answer graphically. Always mention your recommendation while questioning.