Claude Skill

wp-plugin-presenter

Design and review native presenter classes in WordPress

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-plugin-scaffold_wp-plugin-presenter-52f6020.zip · 6 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/plugin-scaffold/wp-plugin-presenter
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

WordPress plugin: native presenters

For plugin code that turns DTOs / domain objects into output shapes. A presenter chooses fields, computes labels, formats dates/numbers, redacts sensitive values, and returns arrays that controllers can send to REST, AJAX, admin tables, JS config, email templates, exports, or views.

This skill is intentionally better-data-free. If the project already uses better-data, run bd-presenter; otherwise use this native pattern.

When to load references

Misconception this skill corrects

"The DTO already has to_array(), so the controller can return that everywhere."

Wrong. to_array() is usually the DTO's canonical data snapshot. REST output, admin table rows, export rows, JS config, and email variables have different audiences and redaction rules. A presenter makes those contexts explicit instead of letting every controller hand-edit arrays.

When to use this skill

Trigger when ANY of the following is true:

  • Adding or reviewing FooPresenter, FooViewModel, ResponseMapper, AdminRow, JsonPresenter, or similar classes.
  • REST/AJAX code returns arrays derived from DTOs, WP_Post, WP_User, WooCommerce objects, options, or custom table rows.
  • Code calls wp_send_json_success(), rest_ensure_response(), wp_add_inline_script(), or builds admin table rows.
  • A DTO has sensitive fields and the output needs redaction.
  • A controller currently contains formatting, labels, computed fields, or output-specific conditionals.

Layer boundaries

Layer Responsibility
DTO Normalized data. No audience-specific output.
Presenter Context-specific arrays and computed fields. No DB writes.
Controller Permission check, nonce/REST validation, calls presenter, sends response.
View/template Escapes and echoes HTML.

Presenter output for REST/JSON should be raw JSON-safe primitives, not pre-escaped HTML. Presenter output for an HTML-only view may include already escaped markup, but the method name must make that clear, e.g. render_badge_html().

Minimal class shape

namespace MyPlugin\Presenter;

use MyPlugin\Dto\ProductDto;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

final class ProductPresenter {
    private ProductDto $product;

    public function __construct( ProductDto $product ) {
        $this->product = $product;
    }

    /** @return array<string,mixed> */
    public function for_rest(): array {
        return array(
            'id'      => $this->product->id(),
            'title'   => $this->product->title(),
            'enabled' => $this->product->enabled(),
        );
    }

    /** @return array<string,string|int> */
    public function for_admin_table(): array {
        return array(
            'id'     => $this->product->id(),
            'title'  => $this->product->title(),
            'status' => $this->product->enabled() ? __( 'Enabled', 'my-plugin' ) : __( 'Disabled', 'my-plugin' ),
        );
    }
}

Use explicit context methods instead of a generic to_array( $context ) until the contexts genuinely share most of the same shape. Method names make reviews easier: for_rest(), for_admin_table(), for_export(), for_email(), for_js_config().

Output rules by context

  • REST/AJAX: return unescaped scalars, arrays, and nulls. Let WP JSON-encode them.
  • Admin table / template: presenter chooses values; view escapes with esc_html(), esc_attr(), esc_url(), or wp_kses_post().
  • Inline JS config: pass presenter output through wp_json_encode() inside wp_add_inline_script().
  • Email: present subject/body variables separately from HTML template rendering.
  • Export: use stable machine-readable keys and raw scalar values unless the export is explicitly human-facing.

See references/presenter-context-patterns.md for complete examples.

Redaction by default

Presenter methods should be public-safe by default. Sensitive values require explicit opt-in:

public function for_admin_table(): array {
    return array(
        'name'    => $this->credential->name(),
        'api_key' => '***',
    );
}

public function for_private_admin( bool $can_reveal_secret ): array {
    if ( ! $can_reveal_secret ) {
        return $this->for_admin_table();
    }

    $api_key = $this->credential->api_key();

    return array(
        'name'    => $this->credential->name(),
        'api_key' => $api_key ? $api_key->reveal() : '',
    );
}

The controller passes $can_reveal_secret = current_user_can( 'manage_options' ); the presenter applies that already-made authorization decision. Do not create convenience methods like reveal_all() or include secrets in a generic for_rest() response.

Critical rules

  • Presenter never mutates the DTO. Compute output values into arrays.
  • Allowlist fields. Never get_object_vars( $dto ), json_encode( $dto ), or return raw WP/WC objects.
  • One context, one method. for_rest() and for_admin_table() should not share a leaky "everything" array.
  • Redact sensitive fields by default. Explicit reveal only in narrowly named methods, and pass the authorization decision in from the controller.
  • Escape at the final HTML boundary. REST/AJAX/JS config arrays are not HTML.
  • Do not put HTML in REST payloads. If a method returns HTML, name it render_*_html() and escape inside it.
  • Keep DB and WP writes out. Presenter may call formatting/i18n helpers, but not repositories, update_option(), $wpdb, or remote APIs.
  • Collections replay per-item presenters. No duplicated mapping logic.

Common mistakes

// WRONG - exposes every public property and misses redaction.
return get_object_vars( $dto );

// WRONG - REST payload contains HTML from an admin use case.
return array( 'status' => '<span class="badge">Enabled</span>' );

// WRONG - escaping too early for JSON.
return array( 'title' => esc_html( $dto->title() ) );

// WRONG - side effect in presenter.
update_option( 'myplugin_last_presented', time() );

Cross-references

  • Run wp-plugin-architecture when deciding folder placement, namespaces, or by-feature vs by-type organization.
  • Run wp-plugin-assets-loading when passing presenter output into wp_add_inline_script().
  • Run bd-presenter only if the project intentionally uses the better-data library. better-data automates builder-style presentation; this skill is the native no-library version.

What this skill does NOT cover

  • DTO hydration and validation.
  • Template partial organization or block rendering architecture.
  • better-data Presenter internals.
  • REST route registration and permission callbacks.

References

Files (wp-agent-skills)
  • references
    • before-after-controller-output.md 3.3 KB
      # Before / after: controller output to presenter
      
      Use this reference when controllers return raw objects, raw arrays, or
      pre-escaped JSON payloads.
      
      ## Before: REST callback leaks storage shape
      
      ```php
      public function get_item( \WP_REST_Request $request ) {
          $post = get_post( absint( $request['id'] ) );
      
          if ( ! $post ) {
              return new WP_Error( 'not_found', 'Not found.', array( 'status' => 404 ) );
          }
      
          return rest_ensure_response(
              array_merge(
                  get_object_vars( $post ),
                  get_post_meta( $post->ID )
              )
          );
      }
      ```
      
      Problems:
      
      - Exposes `WP_Post` internals.
      - Leaks all meta, including private/internal keys.
      - Response shape changes when storage changes.
      - No redaction boundary.
      
      ## After: repository -> DTO -> presenter
      
      ```php
      public function get_item( \WP_REST_Request $request ) {
          $dto = $this->repository->find( absint( $request->get_param( 'id' ) ) );
      
          if ( is_wp_error( $dto ) ) {
              return $dto;
          }
      
          return rest_ensure_response(
              ( new ProductPresenter( $dto ) )->for_rest()
          );
      }
      ```
      
      The repository decides how to read WordPress storage. The DTO defines the
      canonical shape. The presenter defines the public response.
      
      ## Before: pre-escaped REST data
      
      ```php
      return rest_ensure_response(
          array(
              'title' => esc_html( $dto->title() ),
              'url'   => esc_url( $dto->url() ),
          )
      );
      ```
      
      Problem: JSON consumers receive HTML-escaped strings. That is not the REST
      contract; it is an HTML rendering concern.
      
      ## After: raw REST data, escaped HTML view
      
      ```php
      return rest_ensure_response(
          ( new ProductPresenter( $dto ) )->for_rest()
      );
      ```
      
      ```php
      $row = ( new ProductPresenter( $dto ) )->for_admin_table();
      
      echo '<a href="' . esc_url( $row['url'] ) . '">'
          . esc_html( $row['title'] )
          . '</a>';
      ```
      
      ## Before: AJAX mixes business and output
      
      ```php
      add_action( 'wp_ajax_myplugin_product', function (): void {
          check_ajax_referer( 'myplugin_product', 'nonce' );
      
          $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
          $post = get_post( $id );
      
          if ( ! $post ) {
              wp_send_json_error( array( 'message' => 'Missing product.' ), 404 );
          }
      
          wp_send_json_success(
              array(
                  'id'    => $post->ID,
                  'title' => esc_html( get_the_title( $post ) ),
                  'html'  => '<strong>' . esc_html( get_the_title( $post ) ) . '</strong>',
              )
          );
      } );
      ```
      
      Problems:
      
      - REST-like data and HTML fragment are mixed.
      - Escaped title is returned as JSON data.
      - No reusable presenter for REST/admin.
      
      ## After: presenter has separate contexts
      
      ```php
      add_action( 'wp_ajax_myplugin_product', function (): void {
          check_ajax_referer( 'myplugin_product', 'nonce' );
      
          $dto = ( new ProductRepository() )->find( isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
      
          if ( is_wp_error( $dto ) ) {
              wp_send_json_error( array( 'message' => $dto->get_error_message() ), 404 );
          }
      
          $presenter = new ProductPresenter( $dto );
      
          wp_send_json_success(
              array(
                  'product' => $presenter->for_rest(),
                  'badge'   => $presenter->render_status_badge_html(),
              )
          );
      } );
      ```
      
      If the endpoint is a pure API, omit `badge`. If the endpoint is specifically a
      partial-render endpoint, the `render_*_html()` method name makes that explicit.
      
    • presenter-context-patterns.md 5.1 KB
      # Presenter context patterns
      
      Use these examples when implementing output for more than one audience. Keep
      presenters side-effect-free: no DB writes, no remote calls, no repository calls.
      
      ## REST presenter
      
      ```php
      final class ProductPresenter {
          private ProductDto $product;
      
          public function __construct( ProductDto $product ) {
              $this->product = $product;
          }
      
          /** @return array<string,mixed> */
          public function for_rest(): array {
              return array(
                  'id'         => $this->product->id(),
                  'title'      => $this->product->title(),
                  'price'      => $this->product->price(),
                  'enabled'    => $this->product->enabled(),
                  'created_at' => $this->product->created_at()
                      ? $this->product->created_at()->format( \DateTimeInterface::ATOM )
                      : null,
              );
          }
      }
      ```
      
      REST values are not HTML. Do not call `esc_html()` here.
      
      ## Admin table presenter
      
      ```php
      /** @return array<string,string|int> */
      public function for_admin_table(): array {
          return array(
              'id'      => $this->product->id(),
              'title'   => $this->product->title(),
              'price'   => number_format_i18n( $this->product->price(), 2 ),
              'status'  => $this->product->enabled()
                  ? __( 'Enabled', 'my-plugin' )
                  : __( 'Disabled', 'my-plugin' ),
          );
      }
      ```
      
      Escape when echoing:
      
      ```php
      $row = ( new ProductPresenter( $dto ) )->for_admin_table();
      
      echo '<td>' . esc_html( $row['title'] ) . '</td>';
      echo '<td>' . esc_html( $row['price'] ) . '</td>';
      echo '<td>' . esc_html( $row['status'] ) . '</td>';
      ```
      
      ## Rendered HTML helper
      
      If the presenter returns HTML, make that explicit in the method name and escape
      inside the method.
      
      ```php
      public function render_status_badge_html(): string {
          $class = $this->product->enabled() ? 'myplugin-badge--ok' : 'myplugin-badge--muted';
          $label = $this->product->enabled()
              ? __( 'Enabled', 'my-plugin' )
              : __( 'Disabled', 'my-plugin' );
      
          return sprintf(
              '<span class="myplugin-badge %s">%s</span>',
              esc_attr( $class ),
              esc_html( $label )
          );
      }
      ```
      
      Do not include this HTML in `for_rest()`.
      
      ## Inline JS config
      
      ```php
      /** @return array<string,mixed> */
      public function for_js_config(): array {
          return array(
              'id'      => $this->product->id(),
              'title'   => $this->product->title(),
              'enabled' => $this->product->enabled(),
          );
      }
      
      wp_add_inline_script(
          'my-plugin-admin',
          'window.MyPluginProduct = ' . wp_json_encode( ( new ProductPresenter( $dto ) )->for_js_config() ) . ';',
          'before'
      );
      ```
      
      Never concatenate raw strings into JavaScript.
      
      ## Email variables
      
      ```php
      /** @return array<string,string> */
      public function for_email(): array {
          return array(
              'title'  => $this->product->title(),
              'price'  => number_format_i18n( $this->product->price(), 2 ),
              'status' => $this->product->enabled()
                  ? __( 'enabled', 'my-plugin' )
                  : __( 'disabled', 'my-plugin' ),
          );
      }
      ```
      
      The email template decides whether variables go into text or HTML and escapes
      accordingly.
      
      ## Export presenter
      
      ```php
      /** @return array<string,string|int|float> */
      public function for_export(): array {
          return array(
              'id'      => $this->product->id(),
              'title'   => $this->product->title(),
              'price'   => $this->product->price(),
              'enabled' => $this->product->enabled() ? 'yes' : 'no',
          );
      }
      ```
      
      Exports need stable keys. Avoid translated keys unless the export is explicitly
      human-facing and locale-specific.
      
      ## Redaction pattern
      
      ```php
      final class ApiCredentialPresenter {
          private ApiCredentialDto $credential;
      
          public function __construct( ApiCredentialDto $credential ) {
              $this->credential = $credential;
          }
      
          public function for_rest(): array {
              return array(
                  'name'    => $this->credential->name(),
                  'api_key' => '***',
              );
          }
      
          public function for_private_admin( bool $can_reveal_secret ): array {
              if ( ! $can_reveal_secret ) {
                  return $this->for_rest();
              }
      
              $api_key = $this->credential->api_key();
      
              return array(
                  'name'    => $this->credential->name(),
                  'api_key' => $api_key ? $api_key->reveal() : '',
              );
          }
      }
      ```
      
      The controller supplies the authorization decision. Do not call
      `current_user_can()` deep inside generic presentation code unless the local
      project already uses that convention.
      
      ## Collection presenter
      
      ```php
      final class ProductCollectionPresenter {
          /** @var ProductDto[] */
          private array $products;
      
          /** @param ProductDto[] $products */
          public function __construct( array $products ) {
              $this->products = $products;
          }
      
          /** @return array<int,array<string,mixed>> */
          public function for_rest(): array {
              return array_map(
                  static fn ( ProductDto $product ): array => ( new ProductPresenter( $product ) )->for_rest(),
                  $this->products
              );
          }
      }
      ```
      
      Collection presenters replay the per-item presenter. They do not duplicate the
      field mapping.
      
  • SKILL.md 8.8 KB
    ---
    name: wp-plugin-presenter
    description: Design and review native presenter classes in WordPress
      plugins without requiring better-data - converting DTOs or domain
      objects into REST arrays, admin table rows, JS config payloads, email
      variables, and public view models with allowlisted fields, context
      methods, redaction by default, locale/date/number formatting, no DTO
      mutation, and correct WordPress escaping boundaries. Use when adding
      FooPresenter, response mappers, admin-row arrays, wp_send_json payloads,
      rest_ensure_response data, wp_add_inline_script config, or when code
      returns raw DTOs, WP_Post, WC_Order, get_object_vars, json_encode, or
      unescaped HTML from controllers.
    metadata:
      wp-skills-author: "Soczo Kristof"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.3 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # WordPress plugin: native presenters
    
    For plugin code that turns DTOs / domain objects into output shapes. A
    presenter chooses fields, computes labels, formats dates/numbers, redacts
    sensitive values, and returns arrays that controllers can send to REST, AJAX,
    admin tables, JS config, email templates, exports, or views.
    
    This skill is intentionally **better-data-free**. If the project already uses
    better-data, run `bd-presenter`; otherwise use this native pattern.
    
    ## When to load references
    
    - Need complete REST/admin/JS/email/export presenter examples, collection presenter, or redaction pattern: read [references/presenter-context-patterns.md](references/presenter-context-patterns.md).
    - Refactoring a controller that returns raw DTOs, raw arrays, `WP_Post`, `WC_Order`, `get_object_vars()`, or pre-escaped REST payloads: read [references/before-after-controller-output.md](references/before-after-controller-output.md).
    
    ## Misconception this skill corrects
    
    > "The DTO already has `to_array()`, so the controller can return that everywhere."
    
    Wrong. `to_array()` is usually the DTO's canonical data snapshot. REST output,
    admin table rows, export rows, JS config, and email variables have different
    audiences and redaction rules. A presenter makes those contexts explicit
    instead of letting every controller hand-edit arrays.
    
    ## When to use this skill
    
    Trigger when ANY of the following is true:
    
    - Adding or reviewing `FooPresenter`, `FooViewModel`, `ResponseMapper`, `AdminRow`, `JsonPresenter`, or similar classes.
    - REST/AJAX code returns arrays derived from DTOs, `WP_Post`, `WP_User`, WooCommerce objects, options, or custom table rows.
    - Code calls `wp_send_json_success()`, `rest_ensure_response()`, `wp_add_inline_script()`, or builds admin table rows.
    - A DTO has sensitive fields and the output needs redaction.
    - A controller currently contains formatting, labels, computed fields, or output-specific conditionals.
    
    ## Layer boundaries
    
    | Layer | Responsibility |
    |---|---|
    | DTO | Normalized data. No audience-specific output. |
    | Presenter | Context-specific arrays and computed fields. No DB writes. |
    | Controller | Permission check, nonce/REST validation, calls presenter, sends response. |
    | View/template | Escapes and echoes HTML. |
    
    Presenter output for REST/JSON should be raw JSON-safe primitives, not
    pre-escaped HTML. Presenter output for an HTML-only view may include already
    escaped markup, but the method name must make that clear, e.g.
    `render_badge_html()`.
    
    ## Minimal class shape
    
    ```php
    namespace MyPlugin\Presenter;
    
    use MyPlugin\Dto\ProductDto;
    
    if ( ! defined( 'ABSPATH' ) ) {
        exit;
    }
    
    final class ProductPresenter {
        private ProductDto $product;
    
        public function __construct( ProductDto $product ) {
            $this->product = $product;
        }
    
        /** @return array<string,mixed> */
        public function for_rest(): array {
            return array(
                'id'      => $this->product->id(),
                'title'   => $this->product->title(),
                'enabled' => $this->product->enabled(),
            );
        }
    
        /** @return array<string,string|int> */
        public function for_admin_table(): array {
            return array(
                'id'     => $this->product->id(),
                'title'  => $this->product->title(),
                'status' => $this->product->enabled() ? __( 'Enabled', 'my-plugin' ) : __( 'Disabled', 'my-plugin' ),
            );
        }
    }
    ```
    
    Use explicit context methods instead of a generic `to_array( $context )` until
    the contexts genuinely share most of the same shape. Method names make reviews
    easier: `for_rest()`, `for_admin_table()`, `for_export()`, `for_email()`,
    `for_js_config()`.
    
    ## Output rules by context
    
    - **REST/AJAX:** return unescaped scalars, arrays, and nulls. Let WP JSON-encode them.
    - **Admin table / template:** presenter chooses values; view escapes with `esc_html()`, `esc_attr()`, `esc_url()`, or `wp_kses_post()`.
    - **Inline JS config:** pass presenter output through `wp_json_encode()` inside `wp_add_inline_script()`.
    - **Email:** present subject/body variables separately from HTML template rendering.
    - **Export:** use stable machine-readable keys and raw scalar values unless the export is explicitly human-facing.
    
    See [references/presenter-context-patterns.md](references/presenter-context-patterns.md)
    for complete examples.
    
    ## Redaction by default
    
    Presenter methods should be public-safe by default. Sensitive values require
    explicit opt-in:
    
    ```php
    public function for_admin_table(): array {
        return array(
            'name'    => $this->credential->name(),
            'api_key' => '***',
        );
    }
    
    public function for_private_admin( bool $can_reveal_secret ): array {
        if ( ! $can_reveal_secret ) {
            return $this->for_admin_table();
        }
    
        $api_key = $this->credential->api_key();
    
        return array(
            'name'    => $this->credential->name(),
            'api_key' => $api_key ? $api_key->reveal() : '',
        );
    }
    ```
    
    The controller passes `$can_reveal_secret = current_user_can( 'manage_options' )`;
    the presenter applies that already-made authorization decision. Do not create
    convenience methods like `reveal_all()` or include secrets in a generic
    `for_rest()` response.
    
    ## Critical rules
    
    - **Presenter never mutates the DTO.** Compute output values into arrays.
    - **Allowlist fields.** Never `get_object_vars( $dto )`, `json_encode( $dto )`, or return raw WP/WC objects.
    - **One context, one method.** `for_rest()` and `for_admin_table()` should not share a leaky "everything" array.
    - **Redact sensitive fields by default.** Explicit reveal only in narrowly named methods, and pass the authorization decision in from the controller.
    - **Escape at the final HTML boundary.** REST/AJAX/JS config arrays are not HTML.
    - **Do not put HTML in REST payloads.** If a method returns HTML, name it `render_*_html()` and escape inside it.
    - **Keep DB and WP writes out.** Presenter may call formatting/i18n helpers, but not repositories, `update_option()`, `$wpdb`, or remote APIs.
    - **Collections replay per-item presenters.** No duplicated mapping logic.
    
    ## Common mistakes
    
    ```php
    // WRONG - exposes every public property and misses redaction.
    return get_object_vars( $dto );
    
    // WRONG - REST payload contains HTML from an admin use case.
    return array( 'status' => '<span class="badge">Enabled</span>' );
    
    // WRONG - escaping too early for JSON.
    return array( 'title' => esc_html( $dto->title() ) );
    
    // WRONG - side effect in presenter.
    update_option( 'myplugin_last_presented', time() );
    ```
    
    ## Cross-references
    
    - Run **`wp-plugin-architecture`** when deciding folder placement, namespaces, or by-feature vs by-type organization.
    - Run **`wp-plugin-assets-loading`** when passing presenter output into `wp_add_inline_script()`.
    - Run **`bd-presenter`** only if the project intentionally uses the better-data library. better-data automates builder-style presentation; this skill is the native no-library version.
    
    ## What this skill does NOT cover
    
    - DTO hydration and validation.
    - Template partial organization or block rendering architecture.
    - better-data Presenter internals.
    - REST route registration and permission callbacks.
    
    ## References
    
    - [references/presenter-context-patterns.md](references/presenter-context-patterns.md) - complete presenter context examples.
    - [references/before-after-controller-output.md](references/before-after-controller-output.md) - controller refactor examples.
    - `rest_ensure_response()` for REST controllers.
    - `wp_send_json_success()` / `wp_send_json_error()` for AJAX.
    - `wp_json_encode()` and `wp_add_inline_script()` for safe JS config.
    - Official documentation: <https://developer.wordpress.org/plugins/security/validating-sanitizing-escaping/>
    - Official documentation: <https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/rest_ensure_response/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/wp_json_encode/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related