Skip to main content

Custom Post Type Entities

ModulesPress provides a powerful and type-safe way to work with WordPress Custom Post Types through its Entity system. This guide covers everything you need to know about creating and working with CPT Entities.

Overview​

CPT Entities are PHP classes that represent WordPress custom post types with additional meta fields and taxonomies. They provide a modern, object-oriented approach to working with WordPress data, eliminating the need for manual post type registration and meta field handling.

Why Use CPT Entities?
  • Type-safe data handling
  • Automatic WordPress registration
  • Built-in validation
  • Clean, declarative syntax
  • Seamless taxonomy integration

Entity Registration​

CPT Entities (custom post types) are automatically registered when included in a module's entity array:

#[Module(
imports: [],
providers: [],
controllers: [],
exports: [],
entities: [Book::class]
)]
class BooksModule extends ModulesPressModule {}

When ModulesPress boots, it automatically:

  • Registers all Custom Post Types
  • Sets up associated taxonomies

Basic Structure​

Every CPT Entity extends the base CPTEntity class, which provides core WordPress post functionality:

use ModulesPress\Foundation\Entity\CPT\CPTEntity;
use ModulesPress\Foundation\Entity\CPT\Attributes\CustomPostType;

#[CustomPostType(
name: 'book',
singular: 'Book',
plural: 'Books'
)]
class Book extends CPTEntity
{
// Built-in properties from CPTEntity:
// - title: Post title
// - content: Post content
// - excerpt: Post excerpt
// - id: Post ID (managed internally)

public string $title = 'New Book'; // Override default title
}

Core Attributes​

CustomPostType​

The #[CustomPostType] attribute defines the WordPress custom post type configuration. ModulesPress automatically handles the registration process.

#[CustomPostType(
name: 'book', // Post type name (required)
singular: 'Book', // Singular label
plural: 'Books', // Plural label
args: [ // WordPress register_post_type() arguments
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'menu_icon' => 'dashicons-book-alt',
'supports' => ['title', 'editor', 'thumbnail']
]
)]
Post Type Arguments

The args parameter accepts all standard WordPress register_post_type() arguments, giving you full control over the post type's behavior while maintaining a clean, attribute-based syntax.

MetaField​

The #[MetaField] attribute transforms class properties into WordPress meta fields, handling all the storage and retrieval logic automatically.

use ModulesPress\Foundation\Entity\CPT\Attributes\MetaField;

#[MetaField]
public string $edition;

#[MetaField(
key: 'book_author', // Custom meta key (optional)
default: 'Unknown', // Default value (optional)
serialize: 'json_encode', // Serialization function (optional)
deserialize: 'json_decode' // Deserialization function (optional)
)]
public string $author;

ModulesPress automatically:

  • Writes into those meta keys in the WordPress database
  • Handles serialization of complex data types
  • Manages default values
  • Provides type-safe access to meta data

MetaField Options​

OptionDescriptionExampleDefault
keyCustom meta key'book_author'Property name
defaultDefault value'Jonathan Swift'-
serializeSerialization function'json_encode'-
deserializeDeserialization function'json_decode'-

TaxonomyField​

The #[TaxonomyField] attribute creates a seamless connection between your post type and taxonomies:

use ModulesPress\Foundation\Entity\CPT\Attributes\TaxonomyField;

/** @var string[] */
#[TaxonomyField(GenreTaxonomy::class)]
public array $genres;

This automatically:

  • Registers the taxonomy-post type relationship
  • Handles term assignment and retrieval
  • Maintains type safety with arrays of terms

Custom Taxonomies​

Define custom taxonomies using the #[Taxonomy] attribute. ModulesPress handles the WordPress registration automatically:

use ModulesPress\Foundation\Entity\CPT\Attributes\Taxonomy;

#[Taxonomy(
slug: 'genre',
singular: 'Genre',
plural: 'Genres',
args: [
'hierarchical' => true,
'show_in_rest' => true,
'show_admin_column' => true
]
)]
class GenreTaxonomy {}

Symfony Validation​

ModulesPress leverages Symfony's validation component for robust entity validation. Validation is automatically performed when saving entities through repositories.

Basic Validation​

use Symfony\Component\Validator\Constraints;

#[Constraints\NotBlank(message: "Title cannot be empty")]
#[Constraints\Length(min: 3, max: 100)]
#[MetaField]
public string $title;

#[Constraints\Email(message: "Invalid email address")]
#[MetaField]
public string $authorEmail;

Array Validation​

Perfect for handling multiple values:

/** @var string[] */
#[Constraints\All([
new Constraints\Length(min: 5, max: 255),
new Constraints\NotBlank()
])]
#[Constraints\Count(min: 1, max: 5)]
#[MetaField]
public array $tags;

Nested Object Validation​

For complex data structures:

#[Constraints\Valid]
#[MetaField(
serialize: 'json_encode',
deserialize: [self::class, 'deserializePublisher']
)]
public Publisher $publisher;
tip

On failed validations, the ValidationException is thrown, which can be caught using the #[CatchException(ValidationException::class)] attribute. This allows for more targeted exception handling.

Working with Repositories​

While CPT Repositories deserve their own detailed guide, here's how they work with entities:

class BookController
{
public function __construct(
private BookRepository $bookRepository
) {}

#[Post]
public function createBook(#[Body] CreateBookDTO $body)
{
// Create a new book entity
$book = new Book();
$book->name = $body->name;
$book->author = $body->author;
$book->genres = $body->genres;

// Save using repository
$this->bookRepository->save($book);
}

#[Get(':id')]
public function getBook(#[Param('id')] int $id)
{
// Find existing book
$book = $this->bookRepository->find($id);
return $book;
}
}

The repository pattern provides:

  • Clean separation of concerns
  • Type-safe CRUD operations
  • Automatic validation
  • Simplified data access

Advanced Features​

Custom Serialization​

Handle complex data types with custom serialization:

class Book extends CPTEntity
{
#[MetaField(
serialize: [self::class, 'serializeLibrary'],
deserialize: [self::class, 'deserializeLibrary']
)]
public Library $library;

public static function serializeLibrary(Library $library): string
{
return json_encode($library);
}

public static function deserializeLibrary($libraryJson): Library
{
if ($libraryJson instanceof Library) {
return $libraryJson;
}
// In real-world use, you'll likely use a Hydrator
return new Library(json_decode($libraryJson, true));
}
}

Default Values​

You can provide default values for meta fields in various ways:

// using the default attribute
#[MetaField(default: 'Lorem ipsum')]
public string $description;

// or directly assign a value
#[MetaField]
public string $description = 'Lorem ipsum';

// or mark them as nullable, ModulesPress will automatically set them to null if not provided
#[MetaField]
public ?string $description;

//or dynamically generate default values using the default attribute and a callable
#[MetaField(default: [self::class, 'generateDescription'])]
public string $description;

public static function generateDescription(): string
{
return sprintf('Created on %s', date('Y-m-d'));
}

Serialization and Deserialization: Deep Dive​

The WordPress Serialization Challenge​

WordPress uses PHP's native serialize() and unserialize() functions for storing complex objects in meta fields. However, this approach comes with significant risks:

The __PHP_Incomplete_Class Problem​

When an object is serialized and then deserialized, but the original class definition is no longer available (due to:

  • Class name changes
  • Namespace modifications
  • Autoloader path alterations
  • Class file deletion or moved

WordPress will create an __PHP_Incomplete_Class object, which:

  • Loses all method functionality
  • Retains only public property values
  • Prevents proper object reconstruction

Example of the problem:

class OriginalClass {
public $data = 'original';
}

// Serialize and save to meta
$object = new OriginalClass();
update_post_meta($postId, 'complex_data', serialize($object));

// Later, if class is renamed/moved
class RenamedClass {
public $data = 'modified';
}

// Retrieval will create an __PHP_Incomplete_Class
$retrieved = unserialize(get_post_meta($postId, 'complex_data', true));
// $retrieved is now an __PHP_Incomplete_Class instance

ModulesPress Serialization Solution​

ModulesPress provides a robust serialization mechanism using JSON and Hydration. ModulesPress uses this wonderful lightweight package plain-to-class to create objects from plain data.

info

ModulePress also uses this package internally to transform the payload into DTOs for validations.

class Book extends CPTEntity
{
#[MetaField(
// Custom JSON serialization
serialize: "json_encode",

// Custom deserialization with hydration
deserialize: [self::class, 'deserializeLibrary']
)]
public Library $library;

public static function deserializeLibrary($libraryJson): Library
{
// Handle different input types
if ($libraryJson instanceof Library) {
return $libraryJson;
}

// Parse JSON
$parsed = json_decode($libraryJson, true);

// Use Hydrator to create object
$library = (new Hydrator())->create(Library::class, $parsed);
return $library;
}
}

Key Serialization Benefits​

  1. JSON Serialization

    • Human-readable
    • Language-agnostic
    • Immune to class relocation issues
    • Lightweight
  2. Hydration Process

    • Reconstructs objects from plain data
    • Supports type conversion
    • Handles nested objects
    • Provides flexibility in object creation

Advanced Serialization Techniques​

Custom Serialization Methods​

class ComplexEntity extends CPTEntity
{
#[MetaField(
serialize: [self::class, 'customSerialize'],
deserialize: [self::class, 'customDeserialize']
)]
public ComplexObject $data;

public static function customSerialize(ComplexObject $obj): string
{
// Custom serialization logic
return json_encode([
'id' => $obj->getId(),
'name' => $obj->getName(),
'metadata' => $obj->getMetadata()
]);
}

public static function customDeserialize($data): ComplexObject
{
$parsed = is_string($data) ? json_decode($data, true) : $data;

// Efficient deserialization and recommended for simpler objects.
return (new ComplexObjectFactory())->recreate(
$parsed['id'],
$parsed['name'],
$parsed['metadata']
);
}
}

Handling Polymorphic Objects​

class DocumentEntity extends CPTEntity
{
#[MetaField(
serialize: "json_encode",
deserialize: [self::class, 'deserializeDocument']
)]
public DocumentInterface $document;

public static function deserializeDocument($documentJson): DocumentInterface
{
$parsed = json_decode($documentJson, true);

// Polymorphic deserialization
switch ($parsed['type']) {
case 'pdf':
return PDFDocument::fromArray($parsed);
case 'word':
return WordDocument::fromArray($parsed);
default:
throw new \InvalidArgumentException('Unknown document type');
}
}
}

Repository Customization Hooks​

ModulesPress repositories provide several customization points:

class BookRepository extends CPTRepository
{
// Stabilize entity before saving
protected function stabilizeEntityBeforeSave(CPTEntity $entity, array $postData): CPTEntity
{
// Add default values, normalize data
return parent::stabilizeEntityBeforeSave($entity, $postData);
}

// Transform entity before saving
protected function transformEntityBeforeSave(CPTEntity $entity, array $postData): CPTEntity
{
// Modify entity before persistence
// Useful for:
// - Setting timestamps
// - Generating slugs
// - Adding computed properties
return $entity;
}

// Validate entity before saving
protected function validateEntityBeforeSave(CPTEntity $entity, array $postData): CPTEntity
{
// Additional custom validation
// Can complement Symfony validation
return parent::validateEntityBeforeSave($entity, $postData);
}

// Customize metadata mapping
protected function mapMetaToEntity(WP_Post $post, CPTEntity $entity): CPTEntity
{
// Custom metadata handling
return parent::mapMetaToEntity($post, $entity);
}
}

Customization Hooks Overview​

  1. stabilizeEntityBeforeSave(): Ensure entity has all required data
  2. transformEntityBeforeSave(): Modify entity before saving
  3. validateEntityBeforeSave(): Add repository-level validation
  4. mapMetaToEntity(): Custom metadata mapping
  5. mapTaxonomyToEntity(): Custom taxonomy mapping

These hooks provide extensive flexibility in entity management, allowing you to:

  • Normalize data
  • Add computed properties
  • Implement complex validation
  • Handle special serialization scenarios

Complete Example​

Here's a comprehensive example showcasing various features:

#[CustomPostType(
name: 'book',
singular: 'Book',
plural: 'Books',
args: [
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
]
)]
class Book extends CPTEntity
{
public string $title = 'New Book';

#[Constraints\Length(min: 5, max: 255)]
#[Constraints\NotNull]
#[MetaField]
public string $name;

#[Constraints\Length(min: 5, max: 255)]
#[Constraints\NotNull]
#[MetaField]
public string $author;

#[MetaField(default: [self::class, 'generateDescription'])]
#[Constraints\NotNull]
public string $description;

/** @var string[] */
#[Constraints\All([
new Constraints\Length(min: 5, max: 255),
new Constraints\NotBlank()
])]
#[Constraints\Count(min: 1, max: 5)]
#[MetaField]
public array $socialMedia;

#[Constraints\Valid]
#[Constraints\NotNull]
#[MetaField(
default: new Library(),
key: "lib",
serialize: "json_encode",
deserialize: [self::class, 'deserializeLibrary']
)]
public Library $library;

/** @var string[] */
#[Constraints\All([
new Constraints\Length(min: 5, max: 255),
new Constraints\NotBlank()
])]
#[Constraints\Count(min: 1, max: 5)]
#[TaxonomyField(GenreTaxonomy::class)]
public array $genres;

public function getTitleWithDesc(): string
{
return $this->title . " - " . $this->description;
}
}

Best Practices​

  1. Type Safety

    • Always use proper type hints
    • Include PHPDoc annotations for arrays
    • Leverage PHP 8.0+ features
  2. Validation

    • Add appropriate Symfony constraints
    • Use custom messages for better UX
    • Validate nested objects
  3. Documentation

    • Document complex serialization logic
    • Explain non-obvious default values
    • Add PHPDoc for public methods
  4. Architecture

    • Keep entities focused on data structure
    • Use repositories for data access
    • Separate business logic
  5. Performance

    • Use appropriate serialization methods
    • Consider caching for complex queries
    • Monitor meta query performance
Pro Tip

Use the built-in properties (title, content, excerpt) when possible instead of creating custom meta fields for these standard WordPress fields.

Common Pitfalls​

  • Registration Order: Ensure taxonomies are registered before being used in entities
  • Serialization: Handle edge cases in custom serialization methods
  • Validation: Don't forget to validate nested objects
  • Default Values: Be careful with dynamic defaults that might change
  • Meta Queries: Consider performance with complex meta field searches

Conclusion​

CPT Entities in ModulesPress provide a modern, type-safe way to work with WordPress custom post types. By combining PHP 8 attributes, Symfony validation, and automatic WordPress integration, they offer a powerful foundation for building scalable WordPress plugins.