- WordPress Coding Standards
- File Organization
- Naming Conventions
- Namespaces and Imports
- Hook Patterns
- Documentation Standards
- Security Practices
- Performance Considerations
- Error Handling
- Cron-Specific Rules
The ATmosphere plugin follows the WordPress Coding Standards (WPCS) with the following PHPCS configuration:
| Standard | Purpose |
|---|---|
| WordPress | Full WordPress Coding Standards. |
| PHPCompatibility | PHP 8.2+ compatibility (matches Requires PHP in atmosphere.php). |
| PHPCompatibilityWP | WordPress 6.2+ compatibility (matches Requires at least in readme.txt). |
| VariableAnalysis | Flags undefined or unused variables. |
The full ruleset lives in phpcs.xml. Run composer lint to check and composer lint:fix to auto-fix what can be fixed.
// Tabs for indentation.
function example_function() {
→ $variable = 'value';
→ if ( $condition ) {
→ → do_something();
→ }
}
// Spaces inside parentheses.
if ( $condition ) { // Correct.
if ($condition) { // Incorrect.
// Spaces around operators.
$sum = $a + $b;
// Use array() — not the short syntax [].
$array = array(
→ 'key_one' => 'value',
→ 'key_two' => 'value',
→ 'key_three' => 'value',
);if ( $condition ) {
→ // Code.
} elseif ( $other_condition ) {
→ // Code.
} else {
→ // Code.
}
switch ( $variable ) {
→ case 'value1':
→ → do_something();
→ → break;
→ case 'value2':
→ → do_something_else();
→ → break;
→ default:
→ → do_default();
}
foreach ( $items as $key => $item ) {
→ process_item( $item );
}Yoda conditions are preferred for value-against-variable comparisons, to prevent accidental assignment:
if ( 'value' === $variable ) {
if ( true === $condition ) {
if ( null !== $result ) {Readable conditions (no value-on-the-left to flip) are fine without Yoda:
if ( $user->has_cap( 'edit_posts' ) ) {
if ( \is_array( $data ) ) {class-{name}.php # Regular classes.
trait-{name}.php # Traits.
interface-{name}.php # Interfaces (e.g. interface-content-parser.php).
functions.php # Global functions.
<?php
/**
* {Feature} class file.
*
* @package Atmosphere
* @subpackage {Component}
* @since {version}
*/
namespace Atmosphere\{Component};
use Atmosphere\Other\Class;
use WP_Error;
/**
* {Feature} Class.
*
* Handles {what the class does}.
*
* @since {version}
*/
class {Feature} {Use the literal unreleased for @since and @deprecated markers on new code — the release script rewrites them at release time.
| Element | Convention | Example |
|---|---|---|
| Classes | Pascal_Snake_Case |
class Reaction_Sync |
| Methods | snake_case |
public function get_rkey() |
| Functions | snake_case |
function to_iso8601() |
| Properties | snake_case |
private $access_token |
| Constants | UPPER_SNAKE_CASE |
const META_TID = '_atmosphere_bsky_tid'; |
| Hooks | snake_case, atmosphere_ prefix |
\apply_filters( 'atmosphere_should_publish_comment', … ); |
| Files | hyphen-case, class- prefix |
class-reaction-sync.php |
| Namespaces | PascalCase, one segment per directory |
namespace Atmosphere\OAuth; |
Always use 'atmosphere':
\__( 'Text', 'atmosphere' );
\esc_html_e( 'Text', 'atmosphere' );
\_n( 'one', 'many', $count, 'atmosphere' );WordPress and PHP global functions are always backslash-prefixed in namespaced code. This is a project convention — PHP would fall back to the global scope anyway, but the explicit backslash makes the global call site visible at a glance and prevents accidental shadowing:
\get_option( 'atmosphere_settings' );
\add_action( 'init', …, );
\apply_filters( 'atmosphere_should_publish_comment', $bool, $comment );
\is_wp_error( $result );
\strlen( $body );
\time();Never inline \Atmosphere\OAuth\Client — import it once at the top of the file:
use Atmosphere\OAuth\Client;
use Atmosphere\Transformer\Post;
use function Atmosphere\get_did;
use function Atmosphere\is_connected;// Filters return a value; actions are fire-and-forget.
\apply_filters( 'atmosphere_{subject}', $value );
\apply_filters( 'atmosphere_{subject}_{context}', $value, $extra );
\do_action( 'atmosphere_{event}', $context… );Transform filters (mutate the record array before write):
\apply_filters( 'atmosphere_transform_bsky_post', $record, $post );
\apply_filters( 'atmosphere_transform_comment', $record, $comment );
\apply_filters( 'atmosphere_transform_document', $record, $post );
\apply_filters( 'atmosphere_transform_publication', $record );Content / composition filters:
\apply_filters( 'atmosphere_content_parser', $parser, $post ); // Deprecated; use Registry::register().
\apply_filters( 'atmosphere_document_content', $content, $post, $parser );
\apply_filters( 'atmosphere_document_links', null, $post );
\apply_filters( 'atmosphere_document_labels', null, $post );
\apply_filters( 'atmosphere_document_contributors', null, $post );
\apply_filters( 'atmosphere_publication_labels', null );
\apply_filters( 'atmosphere_publication_show_in_discover', (bool) \get_option( 'blog_public', 1 ) );
\apply_filters( 'atmosphere_long_form_composition', $composition, $post );
\apply_filters( 'atmosphere_teaser_thread_posts', $max_posts, $post );
\apply_filters( 'atmosphere_atproto_preview_transformers', $transformers, $post ); // Add a transformer to the ?atproto={$type} preview.Behaviour / gating filters:
\apply_filters( 'atmosphere_syncable_post_types', array( 'post' ) );
\apply_filters( 'atmosphere_should_publish_comment', $bool, $comment );
\apply_filters( 'atmosphere_should_sync_reply', $bool, $notification, $post_id );
\apply_filters( 'atmosphere_backfill_query_chunk_size', 500 );
\apply_filters( 'atmosphere_oauth_redirect_uri', $uri );
\apply_filters( 'atmosphere_client_metadata', $metadata );
\apply_filters( 'atmosphere_appview_host', 'bsky.app', $path, $context ); // Host/subpath for appview web links; normalized; $context keys: type|did|handle|rkey|tag.
\apply_filters( 'atmosphere_appview_url', $url, $path, $context ); // Whole assembled appview link; rewrite the route from $context.Actions:
\do_action( 'atmosphere_publishing', $post ); // Once per post publish/update/delete schedule.
\do_action( 'atmosphere_publish_post_result', $post, $result );
\do_action( 'atmosphere_publish_comment_result', $comment, $result );
\do_action( 'atmosphere_update_skipped_unsynced_post', $post );
\do_action( 'atmosphere_long_form_strategy_downgraded', $post, $from, $to );
\do_action( 'atmosphere_reaction_synced', $comment_id, $notification, $post_id, $comment_type );Test-only short-circuit:
\apply_filters( 'atmosphere_pre_apply_writes', null, $writes ); // Short-circuit / observe an applyWrites batch.
\apply_filters( 'atmosphere_pre_upload_blob', null, $file_path, $mime_type ); // Short-circuit / observe a blob upload./**
* Short description (one line).
*
* Longer description. Multiple paragraphs are fine.
*
* @since 1.0.0
*
* @see Related_Class
*/
class Example_Class {/**
* Get the stored thread records for a post.
*
* @since 1.0.0
*
* @param int $post_id Post ID.
* @return array[]|\WP_Error Array of records on success, WP_Error on failure.
*/
public function stored_thread_records( $post_id ) {/**
* Cache of resolved DIDs, keyed by handle.
*
* @since 1.0.0
*
* @var array<string, string>
*/
private static $did_cache = array();- Single-line
//for brief clarifications. - Block
/* */for multi-line context. Avoid stacking consecutive//lines for a paragraph — use a block comment instead. /**DocBlocks are for functions, classes, methods, properties, and constants.
// Single-line clarification.
/*
* Multi-line block comment for context that spans
* more than one line.
*/
// TODO: Implement caching.
// FIXME: Handle the edge case when the PDS returns 410.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Needed for batch insert performance.$text = \sanitize_text_field( $_POST['text'] );
$body = \sanitize_textarea_field( $_POST['body'] );
$url = \sanitize_url( $_POST['url'] );
$email = \sanitize_email( $_POST['email'] );
$slug = \sanitize_key( $_POST['slug'] );
$int = \absint( $_POST['count'] );
$html = \wp_kses_post( $_POST['html'] );echo \esc_html( $text );
echo \esc_html__( 'Translatable text', 'atmosphere' );
echo '<input value="' . \esc_attr( $value ) . '">';
echo '<a href="' . \esc_url( $url ) . '">Link</a>';
echo '<script>var data = ' . \wp_json_encode( $data ) . ';</script>';For restricted HTML:
echo \wp_kses(
$html,
array(
'a' => array( 'href' => array(), 'title' => array() ),
'br' => array(),
'em' => array(),
'strong' => array(),
)
);Never concatenate user input into a query. Always use $wpdb->prepare():
$sql = $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}atmosphere_x WHERE post_id = %d AND collection = %s",
$post_id,
$collection
);// Issue.
\wp_nonce_field( 'atmosphere_save_settings', 'atmosphere_nonce' );
// Verify.
if ( ! \isset( $_POST['atmosphere_nonce'] )
|| ! \wp_verify_nonce( $_POST['atmosphere_nonce'], 'atmosphere_save_settings' ) ) {
\wp_die( \__( 'Security check failed.', 'atmosphere' ) );
}
// AJAX.
\check_ajax_referer( 'atmosphere_ajax', 'nonce' );if ( ! \current_user_can( 'manage_options' ) ) {
\wp_die( \__( 'Insufficient permissions.', 'atmosphere' ) );
}
if ( ! \current_user_can( 'edit_post', $post_id ) ) {
return new \WP_Error( 'forbidden', \__( 'Access denied.', 'atmosphere' ) );
}OAuth tokens, DPoP private keys, and refresh tokens must go through Atmosphere\OAuth\Encryption. Never store or log them in plaintext.
AT Protocol records are remote, site-wide state. Treat a post as publishable only when all three checks pass:
post_status === 'publish'.- The post type is supported by ATmosphere.
post_passwordis empty.
Do not use post_password_required() for federation output. It depends on the current visitor's unlock cookie, so an editor who has unlocked a protected post locally could cause protected fields to be serialized into PDS records.
Previously-published posts that leave public visibility must delete remote records, not send an update carrying redacted content. This includes draft, pending, private, trash, custom non-public statuses, applying a password, and removing post type support after records already exist. A status transition may queue the normal delete event; a stale publish/update cron callback must re-check visibility and call Publisher::delete_post( $post ) directly when local record metadata exists.
// Transients for cross-request reads.
$cache_key = 'atmosphere_resolve_' . \md5( $handle );
$cached = \get_transient( $cache_key );
if ( false === $cached ) {
$cached = self::resolve_handle( $handle );
\set_transient( $cache_key, $cached, \HOUR_IN_SECONDS );
}
// Object cache.
\wp_cache_set( 'atmosphere_did_' . $handle, $did, 'atmosphere', \HOUR_IN_SECONDS );
// Per-request static cache for hot paths.
class Resolver {
private static $cache = array();
public static function get( $handle ) {
if ( ! isset( self::$cache[ $handle ] ) ) {
self::$cache[ $handle ] = self::resolve( $handle );
}
return self::$cache[ $handle ];
}
}Any list-like meta or PDS response must have a hard upper bound. Example: Publisher::record_thread_rollback_failure() caps META_ORPHAN_RECORDS at 10 entries so a stuck cron can't grow a wp_postmeta row past max_allowed_packet. Follow the same pattern when adding new manifests.
// Use get_posts() with `fields => 'ids'` if you only need IDs.
$ids = \get_posts( array(
'post_type' => 'post',
'posts_per_page' => 50,
'fields' => 'ids',
'meta_key' => Post::META_URI,
) );
// Batch inserts when looping is otherwise N queries.
$values = array();
foreach ( $items as $item ) {
$values[] = $wpdb->prepare( '(%s, %s)', $item['key'], $item['value'] );
}
if ( $values ) {
$wpdb->query( "INSERT INTO {$table} (key, value) VALUES " . implode( ',', $values ) );
}return new \WP_Error(
'atmosphere_pds_unreachable',
\__( 'PDS could not be reached.', 'atmosphere' ),
array( 'status' => 502, 'pds' => $pds )
);Use error codes prefixed with atmosphere_ so callers can pattern-match. Include any context that helps the caller decide on retry / fallback.
$result = API::apply_writes( $writes );
if ( \is_wp_error( $result ) ) {
self::log_cron_error( 'publish_post', $post_id, $result );
return $result;
}$errors = new \WP_Error();
if ( empty( $args['handle'] ) ) {
$errors->add( 'missing_handle', \__( 'Handle is required.', 'atmosphere' ) );
}
if ( empty( $args['pds'] ) ) {
$errors->add( 'missing_pds', \__( 'PDS is required.', 'atmosphere' ) );
}
if ( $errors->has_errors() ) {
return $errors;
}try {
$result = self::risky_operation();
} catch ( \Exception $e ) {
\Atmosphere\debug_log( $e->getMessage() );
return new \WP_Error( 'atmosphere_exception', $e->getMessage(), array( 'code' => $e->getCode() ) );
}Never call \error_log() directly. Route every log line through Atmosphere\debug_log( string $message ) (includes/functions.php). error_log() honours the server's log_errors / error_log directives independently of WP_DEBUG, so unconditional calls land in production logs on any site with PHP error logging enabled. debug_log():
- No-ops unless
WP_DEBUGis true, so production stays quiet by default. - Adds the
[atmosphere]prefix and collapses CRLF (PDS-supplied error strings can carry attacker-controlled newlines / forged prefixes) in one place — pass the message without the prefix and without pre-stripping newlines. - Exposes the
atmosphere_debug_logfilter (bool $enabled, string $message) so operators can opt into the genuine anomaly breadcrumbs — failed cron PDS writes, thread-rollback orphans — without enablingWP_DEBUGsite-wide.
Every plugin-owned wp_schedule_* hook MUST appear in Atmosphere\get_cron_hooks() (includes/functions.php). That single list is consumed by:
Atmosphere\deactivate()(atmosphere.php)Atmosphere\OAuth\Client::disconnect()(includes/oauth/class-client.php)uninstall.php
When adding a new cron hook:
- Add it to
get_cron_hooks()— do not duplicate the literal in deactivate / disconnect / uninstall. - If the handler issues PDS writes without re-checking
is_connected()(e.g.atmosphere_delete_records,atmosphere_delete_comment_record), the symmetry is load-bearing — a queued event from a previous connection would otherwise fire against a different repo on reconnect. - If the handler stores or sweeps post/comment meta keys, mirror those keys in
uninstall.php.
This pattern was extracted in PR #32; see review by @kraftbj for the cross-install risk that motivated it.
Cron handlers in register_async_hooks() MUST surface Publisher::* errors via debug_log() — typically through log_cron_error(). wp_schedule_single_event does not retry, so a silent drop loses the only signal operators have for transient PDS failures, expired refresh tokens, or DPoP nonce drift. The line is gated behind WP_DEBUG by default; operators who need these breadcrumbs on production without enabling debugging site-wide can opt in via the atmosphere_debug_log filter (see Logging).
When the handler operates on records the caller has already lost local state for (e.g. atmosphere_delete_comment_record after the WP comment row is gone), include the TID/identifier in the log line so the orphan is recoverable manually.
When a cron handler writes meta both before an apply_writes call (e.g. Comment::get_rkey() persists META_TID) and after (e.g. store_comment_result() writes META_URI), and a concurrent state change can short-circuit the cleanup gates that key off the post-call meta, the handler MUST re-check eligibility after the call returns and roll back if needed.
Concrete pattern: atmosphere_publish_comment → reconcile_comment_after_publish(). Re-fetch the WP object, re-run the eligibility gate, schedule the orphan-cleanup cron (not direct delete) so transient PDS failures retry through the standard channel.
The same cron callback can fire twice — concurrent workers, plugin deactivate→reactivate (which re-runs register_schedules()), wp cron event run, or traffic spikes triggering overlapping loopback requests. If the handler has user-visible side effects (a Bluesky post, a mirrored reaction), gate on a meta sentinel before the side effect, not after.