PHP & WordPress Coding Standards¶
This document is the canonical reference for all custom PHP code in this repository. It covers PHP 8.1+ idioms, WordPress patterns specific to this project, and ACF/Formidable Forms conventions.
Tooling enforces a subset of these rules automatically. Run composer lint (Pint, formatting), composer lint:phpcs (WordPress Coding Standards), and composer analyse (PHPStan static analysis) before committing. The rules that tools cannot check — hook architecture decisions, nonce strategy, ACF field references — are documented here and enforced by code review.
PHP Conventions¶
File-level rules¶
Every custom PHP file must begin with <?php followed immediately by declare(strict_types=1);. This is non-negotiable: strict types prevent silent type coercion bugs that PHP's default mode allows.
Plugin and theme files that WordPress could load directly (i.e., everything in web/app/mu-plugins/ and web/app/themes/) must include a direct-access guard as the second statement:
One class per file. The filename must match the class name per PSR-4 conventions. Autoloading is handled by Composer.
Type system¶
PHP 8.1+ type declarations are not optional — use them everywhere:
- All function and method parameters must have type declarations
- All functions and methods must declare a return type (including
void) - All class properties must use typed property declarations, not docblock-only annotations
- Use
readonlyfor properties that are set only in the constructor and never mutated - Use enums for fixed sets of values instead of class constants
- Use union types (
int|string) and nullable types (?string) as needed rather thanmixed
Named arguments are encouraged for WordPress functions that have many positional parameters, where calling with explicit names improves readability:
// Preferred
register_post_type('article', post_type_args: $args);
// Less readable
register_post_type('article', $args);
Control flow and expressions¶
- Use
matchexpressions instead ofswitchwhen assigning or returning a value —matchis strict, exhaustive, and expression-based - Use the null coalescing operator (
??) instead ofisset($x) ? $x : $default - Use arrow functions (
fn($x) => $x * 2) for single-expression callbacks - Avoid deeply nested conditions — extract into named variables or use early returns to reduce indentation
Classes and functions¶
- Single responsibility: one class does one thing. If a class needs to be named with "And" or "Manager", it probably needs splitting.
- No global variables. Use WordPress hooks (actions and filters) for plugin communication, or constructor injection for dependencies.
- All public methods must have PHPDoc blocks with
@param,@return, and@throwstags. - Catch specific exception types. A bare
catch (\Exception $e)is only acceptable when immediately re-throwing or wrapping in a more specific type.
WordPress Conventions¶
WordPress serves two purposes in this project: (1) it renders the marketing and lead-gen website directly via themes and templates, and (2) it exposes a REST API consumed by the Flutter app. The rules below apply to both modes unless noted.
Security fundamentals¶
These rules apply everywhere, in both the rendered site and the API layer.
Sanitize all input at the point of receipt. Use the most specific sanitization function for the expected data type:
| Data type | Function |
|---|---|
| Plain text | sanitize_text_field() |
| Email address | sanitize_email() |
| Integer | intval() or absint() for non-negative |
| URL | sanitize_url() |
| HTML content | wp_kses_post() or wp_kses() with an allowlist |
| Filename | sanitize_file_name() |
Escape all output late — as close to the echo as possible, not when the value is first assembled. Use the most specific escaping function:
| Context | Function |
|---|---|
| Plain text in HTML | esc_html() |
| HTML attribute value | esc_attr() |
| URL in href/src/action | esc_url() |
| HTML content | wp_kses_post() |
| JavaScript string | esc_js() |
Nonces on every state-changing request. Forms, AJAX handlers, and admin actions that change state must include a nonce:
// In the form
wp_nonce_field('tinnitus_save_settings', 'tinnitus_nonce');
// In the handler
check_admin_referer('tinnitus_save_settings', 'tinnitus_nonce');
For AJAX, use check_ajax_referer() instead.
Capability checks before privileged actions. Always verify the minimum required capability before reading non-public data or modifying anything:
Database queries via prepared statements. Never interpolate dynamic values into SQL. Always use $wpdb->prepare():
// Correct
$results = $wpdb->get_results(
$wpdb->prepare('SELECT * FROM %i WHERE id = %d', $table, $id)
);
// Never do this
$results = $wpdb->get_results("SELECT * FROM {$table} WHERE id = {$id}");
Hooks architecture¶
All plugin logic is hook-registered, never executed at include time. A mu-plugin file's top-level code may only contain add_action() and add_filter() calls, class definitions, and require/include statements. No side effects at include time.
Prefix all identifiers with tinnitus_ to avoid collisions with WordPress core, third-party plugins, and future dependencies. This applies to:
- Function names:
tinnitus_register_post_types() - Hook tags (custom actions/filters):
tinnitus_after_form_submission - Option names:
tinnitus_settings - Post meta keys:
tinnitus_featured_stat - Transient keys:
tinnitus_external_api_cache - Class names:
Tinnitus_REST_Controller
Use WordPress HTTP APIs for external requests. Never use curl directly:
$response = wp_remote_get('https://api.example.com/data', [
'timeout' => 10,
'headers' => ['Accept' => 'application/json'],
]);
if (is_wp_error($response)) {
// handle error
}
Check for WP_Error before consuming results. Many WordPress functions return WP_Error on failure. Always check with is_wp_error() before using the result.
Template and front-end patterns (rendered site)¶
Enqueue all assets via WordPress APIs. Never add <script> or <link> tags directly in PHP templates:
add_action('wp_enqueue_scripts', function (): void {
wp_enqueue_style(
handle: 'tinnitus-app',
src: get_theme_file_uri('assets/css/app.css'),
ver: wp_get_theme()->get('Version'),
);
});
Compose templates with get_template_part(). Avoid large monolithic template files. Break templates into named parts that can be found and reused.
Escaping in templates follows the same rules as everywhere else. The late-escaping rule is not relaxed in templates — escape immediately before echo.
REST API patterns (Flutter app CMS layer)¶
Every register_rest_route() call must include a complete validation contract:
register_rest_route('tinnitus/v1', '/articles', [
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_articles'],
'permission_callback' => [$this, 'check_read_permission'],
'args' => [
'lang' => [
'type' => 'string',
'enum' => ['en', 'de'],
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
],
],
]);
Rules:
- permission_callback is required. Never use __return_true on endpoints that return non-public data.
- Every argument must have both sanitize_callback and validate_callback.
- Return WP_REST_Response with an explicit HTTP status code. Use WP_Error for error paths (WordPress converts WP_Error to a JSON error response automatically).
- Authentication: use Application Passwords for machine clients, cookie+nonce for browser-based admin requests. Document the chosen mechanism in the API spec.
ACF conventions¶
Reference fields by key, not name. Field names can be renamed in the ACF admin. Field keys (e.g., field_abc123) are stable:
// Correct — uses field key
$value = get_field('field_6a3f92b', $post_id);
// Fragile — breaks if the field is renamed
$value = get_field('featured_stat', $post_id);
Exception — code-defined field groups: When field groups are registered in PHP via acf_add_local_field_group() (as in acf-field-groups.php), field names are version-controlled and cannot be renamed from the admin UI. In these cases, referencing by name is acceptable for readability — e.g., the AI translation plugin uses get_field('summary', $post_id) rather than get_field('field_tinnitus_summary', $post_id).
Guard repeater loops. Always check have_rows() before entering the loop:
if (have_rows('field_6a3f92b', $post_id)) {
while (have_rows('field_6a3f92b', $post_id)) {
the_row();
$item = get_sub_field('field_7b4c01a');
}
}
Check field group population. Before outputting ACF values in a template, confirm the field group is populated to avoid outputting empty markup.
Tooling Reference¶
| Command | Tool | What it checks |
|---|---|---|
composer lint |
Laravel Pint | PHP formatting (PER preset) |
composer lint:fix |
Laravel Pint | Auto-fix formatting |
composer lint:phpcs |
PHP_CodeSniffer | WordPress Coding Standards |
composer lint:fix:phpcs |
PHP Code Beautifier | Auto-fix phpcs violations |
composer analyse |
PHPStan | Static analysis, type safety |
Tooling is scoped to custom code only. The following are excluded from all tools: web/wp/, web/app/plugins/, web/app/mu-plugins/bedrock-disallow-indexing/, web/app/themes/twentytwentyfive/, vendor/.
Config files: pint.json, phpcs.xml.dist, phpstan.neon, .editorconfig.