Complex Models

25 minutes INTERMEDIATE

Master GEMVC's powerful aggregation and composition pattern using the _ prefix convention. Learn how to create complex models with relationships, special models without tables, and multiple aggregated models.

What You'll Learn

_ Prefix Convention

Properties starting with _ are ignored in CRUD

Model Aggregation

Aggregate other models (e.g., ProfileModel in UserModel)

Special Models

Create models without tables for composition

Video Coming Soon...

What is the _ Prefix Convention?

GEMVC has a powerful convention: properties starting with `_` are completely ignored in all CRUD table operations! This allows you to create complex models with aggregation and composition without affecting database operations.

🎯 Key Point:

Properties starting with _ are completely ignored in:

  • INSERT operations
  • UPDATE operations
  • ❌ Table schema generation
  • ✅ But can be used for aggregation/composition!
  • Model Aggregation - Include other models (e.g., ProfileModel in UserModel)
  • Special Models - Create models without tables for composition
  • Multiple Models - Define multiple models as properties
  • Type Safety - Full PHPStan Level 9 support for aggregated models
  • Clean Architecture - Separate database columns from aggregated data
1

How the _ Prefix Convention Works

Property Visibility Rules

GEMVC has specific rules for how properties are handled based on their visibility and naming:

Property Type Database SELECT Queries INSERT/UPDATE
public $name ✅ Included ✅ Returned ✅ Included
protected $password ✅ Included ❌ Not returned ✅ Included
public $_profile ❌ Ignored ❌ Ignored ❌ Ignored

Use Cases:

  • public - Normal database columns returned in queries
  • protected - Database columns NOT returned in SELECT (e.g., password)
  • _property - Aggregation/composition properties (not in database)
2

Example 1: Aggregating a Profile Model

User Model with Profile

The most common use case: aggregating a related model (like ProfileModel) into UserModel. The _profile property is ignored in all database operations.

Step 1: Create ProfileModel (Special Model Without Table)

First, create a ProfileModel that doesn't need a table. This is a "special model" used only for composition:

app/model/ProfileModel.php - Special Model Without Table
<?php
namespace App\Model;

// Special model - no table needed!
// Used only for aggregation/composition
class ProfileModel
{
    public int $id;
    public int $user_id;
    public string $bio;
    public ?string $avatar_url;
    public ?string $website;
    
    // This model doesn't extend Table
    // It's just a data container for aggregation
}

Step 2: Add Profile to UserModel

Now, add the ProfileModel to UserModel using the _ prefix:

app/model/UserModel.php - With Profile Aggregation
<?php
namespace App\Model;

use App\Table\UserTable;
use App\Model\ProfileModel;

class UserModel extends UserTable
{
    // Database columns
    public int $id;
    public string $name;
    public string $email;
    
    // Aggregated model - IGNORED in CRUD operations!
    public ?ProfileModel $_profile = null;
    
    /**
     * Get user with profile loaded
     */
    public function withProfile(): self
    {
        if ($this->_profile === null && $this->id) {
            // Load profile from database
            $profileTable = new ProfileTable();
            $this->_profile = $profileTable->selectByUserId($this->id);
        }
        return $this;
    }
    
    /**
     * Set profile
     */
    public function setProfile(ProfileModel $profile): void
    {
        $this->_profile = $profile;
    }
}

Usage Example:

Using Profile Aggregation
<?php
// Create user
$user = new UserModel();
$user->id = 1;
$user->name = 'John';
$user->email = 'john@example.com';

// Add profile (ignored in database operations!)
$profile = new ProfileModel();
$profile->bio = 'Software Developer';
$profile->avatar_url = 'https://example.com/avatar.jpg';
$user->_profile = $profile;

// Save user (profile is NOT inserted, only user data!)
$user->insertSingleQuery();  // Only inserts: id, name, email

// Later, load user with profile
$user = (new UserTable())->selectById(1);
$user->withProfile();  // Loads profile into _profile property

// Use aggregated data
echo $user->_profile->bio;  // 'Software Developer'
3

Example 2: Aggregating an Array of Models

User Model with Orders Array

You can also aggregate arrays of models. This is perfect for one-to-many relationships:

app/model/UserModel.php - With Orders Array
<?php
namespace App\Model;

use App\Table\UserTable;
use App\Model\OrderModel;

class UserModel extends UserTable
{
    // Database columns
    public int $id;
    public string $name;
    public string $email;
    
    /**
     * Array of Order models - ignored in CRUD operations
     * @var array<OrderModel>
     */
    public array $_orders = [];
    
    /**
     * Get user's orders
     * @return array<OrderModel>
     */
    public function orders(): array
    {
        if (empty($this->_orders) && $this->id) {
            $orderTable = new OrderTable();
            $this->_orders = $orderTable->selectByUserId($this->id);
        }
        return $this->_orders;
    }
    
    /**
     * Add order to user
     */
    public function addOrder(OrderModel $order): void
    {
        $this->_orders[] = $order;
    }
    
    /**
     * Create order for user
     */
    public function createOrder(array $orderData): OrderModel
    {
        $order = new OrderModel();
        $order->user_id = $this->id;
        $order->amount = $orderData['amount'];
        $order->insertSingleQuery();
        
        $this->_orders[] = $order;
        return $order;
    }
}

Usage Example:

Using Orders Array
<?php
// Create user
$user = new UserModel();
$user->id = 1;
$user->name = 'John';

// Add orders (ignored in database operations!)
$order1 = new OrderModel();
$order1->amount = 100;
$user->addOrder($order1);

$order2 = new OrderModel();
$order2->amount = 200;
$user->addOrder($order2);

// Save user (orders array is NOT inserted!)
$user->insertSingleQuery();  // Only inserts: id, name

// Later, create actual orders in database
foreach ($user->_orders as $order) {
    $order->user_id = $user->id;
    $order->insertSingleQuery();  // Save each order separately
}
4

Example 3: Complex Relationships

Product Model with Multiple Aggregations

You can aggregate multiple models in a single model. Here's a ProductModel with Category and Reviews:

app/model/ProductModel.php - Complex Relationships
<?php
namespace App\Model;

use App\Table\ProductTable;
use App\Model\CategoryModel;
use App\Model\ReviewModel;

class ProductModel extends ProductTable
{
    // Database columns
    public int $id;
    public string $name;
    public float $price;
    public ?int $category_id;
    
    /**
     * Single related model
     * @var CategoryModel|null
     */
    public ?CategoryModel $_category = null;
    
    /**
     * Array of related models
     * @var array<ReviewModel>
     */
    public array $_reviews = [];
    
    /**
     * Load product with category and reviews
     */
    public function loadRelations(): self
    {
        if ($this->id) {
            // Load category
            if ($this->category_id) {
                $categoryTable = new CategoryTable();
                $this->_category = $categoryTable->selectById($this->category_id);
            }
            
            // Load reviews
            $reviewTable = new ReviewTable();
            $this->_reviews = $reviewTable->selectByProductId($this->id);
        }
        return $this;
    }
    
    /**
     * Get average rating
     */
    public function getAverageRating(): float
    {
        if (empty($this->_reviews)) {
            return 0.0;
        }
        
        $total = 0;
        foreach ($this->_reviews as $review) {
            $total += $review->rating;
        }
        
        return round($total / count($this->_reviews), 2);
    }
}

Usage Example:

Using Complex Relationships
<?php
// Load product with all relations
$product = (new ProductTable())->selectById(1);
$product->loadRelations();

// Use aggregated data
echo $product->_category->name;  // 'Electronics'
echo $product->getAverageRating();  // 4.5

// Save product (category and reviews are NOT affected!)
$product->price = 999.99;
$product->updateSingleQuery();  // Only updates: id, name, price
5

Creating Special Models Without Tables

Data Container Models

Sometimes you need a model that doesn't have a database table. These are "special models" used only for aggregation and composition. They're simple PHP classes without extending Table.

Example: AddressModel (No Table)

app/model/AddressModel.php - Special Model
<?php
namespace App\Model;

// Special model - no table needed!
// Used only for aggregation/composition
class AddressModel
{
    public string $street;
    public string $city;
    public string $state;
    public string $zip_code;
    public string $country;
    
    // This model doesn't extend Table
    // It's just a data container
    // Can be used in UserModel as: public ?AddressModel $_address = null;
}

Example: SettingsModel (No Table)

app/model/SettingsModel.php - Special Model
<?php
namespace App\Model;

// Special model for user settings
// No database table - just composition
class SettingsModel
{
    public bool $email_notifications = true;
    public bool $sms_notifications = false;
    public string $theme = 'light';
    public string $language = 'en';
    
    // Can be used in UserModel as: public ?SettingsModel $_settings = null;
}

Using Special Models in UserModel

app/model/UserModel.php - Multiple Aggregations
<?php
namespace App\Model;

use App\Table\UserTable;
use App\Model\ProfileModel;
use App\Model\AddressModel;
use App\Model\SettingsModel;

class UserModel extends UserTable
{
    // Database columns
    public int $id;
    public string $name;
    public string $email;
    
    // Multiple aggregated models
    public ?ProfileModel $_profile = null;
    public ?AddressModel $_address = null;
    public ?SettingsModel $_settings = null;
    
    /**
     * Load all aggregated data
     */
    public function loadAll(): self
    {
        if ($this->id) {
            // Load profile
            $this->withProfile();
            
            // Load address
            $addressTable = new AddressTable();
            $this->_address = $addressTable->selectByUserId($this->id);
            
            // Load settings
            $settingsTable = new SettingsTable();
            $this->_settings = $settingsTable->selectByUserId($this->id);
        }
        return $this;
    }
}
6

Common Patterns

Patterns for Different Use Cases

Here are common patterns for using the _ prefix convention:

Pattern 1: Single Model Aggregation

Single Model
<?php
// Single related model
public ?ProfileModel $_profile = null;

// Usage
$user->_profile = $profile;
$user->insertSingleQuery();  // Profile ignored

Pattern 2: Array Aggregation

Array of Models
<?php
// Array of related models
public array $_orders = [];

// Usage
$user->_orders = [$order1, $order2];
$user->insertSingleQuery();  // Orders ignored

Pattern 3: Private with Getter

Private Aggregation
<?php
// Private aggregation with getter
private array $_reviews = [];

public function getReviews(): array
{
    return $this->_reviews;
}

Pattern 4: Protected Aggregation

Protected Aggregation
<?php
// Protected aggregation
protected ?CategoryModel $_category = null;

// Can only be accessed within the class or subclasses
7

PHPStan Level 9 Type Safety

Full Type Safety for Aggregated Models

GEMVC supports full PHPStan Level 9 type safety for aggregated models. Use proper type hints and PHPDoc annotations:

✅ Single Model with Type Hint

Type-Safe Single Model
<?php
// ✅ Nullable single model
public ?ProfileModel $_profile = null;

// PHPStan knows this is ProfileModel or null
if ($this->_profile !== null) {
    echo $this->_profile->bio;  // ✅ Type-safe!
}

✅ Array with PHPDoc

Type-Safe Array
<?php
/**
 * Array of Order models - ignored in CRUD operations
 * @var array<OrderModel>
 */
public array $_orders = [];

// PHPStan knows this is array<OrderModel>
foreach ($this->_orders as $order) {
    echo $order->amount;  // ✅ Type-safe!
}

✅ Typed Array with Index

Typed Array with Index
<?php
/**
 * Array of reviews indexed by review ID
 * @var array<int, ReviewModel>
 */
public array $_reviews = [];

// PHPStan knows keys are int and values are ReviewModel
foreach ($this->_reviews as $reviewId => $review) {
    echo $reviewId;  // ✅ int
    echo $review->rating;  // ✅ ReviewModel property
}

Best Practices

💡 Use Descriptive Names

Use clear, descriptive names for aggregated properties:

✅ Good Names
<?php
public ?ProfileModel $_profile = null;      // ✅ Clear
public array $_orders = [];                 // ✅ Clear
public array $_reviews = [];               // ✅ Clear

// ❌ Avoid
public ?ProfileModel $_p = null;           // ❌ Unclear
public array $_data = [];                  // ❌ Too generic

💡 Add Type Hints

Always add type hints for PHPStan Level 9 compliance:

✅ Type Hints
<?php
// ✅ Single model
public ?ProfileModel $_profile = null;

// ✅ Array with PHPDoc
/** @var array<OrderModel> */
public array $_orders = [];

// ✅ Typed array
/** @var array<int, ReviewModel> */
public array $_reviews = [];

💡 Create Helper Methods

Create helper methods for loading and accessing aggregated data:

✅ Helper Methods
<?php
public function withProfile(): self
{
    if ($this->_profile === null && $this->id) {
        $profileTable = new ProfileTable();
        $this->_profile = $profileTable->selectByUserId($this->id);
    }
    return $this;
}

public function orders(): array
{
    if (empty($this->_orders) && $this->id) {
        $orderTable = new OrderTable();
        $this->_orders = $orderTable->selectByUserId($this->id);
    }
    return $this->_orders;
}

💡 Lazy Loading

Load aggregated data only when needed. Don't load everything upfront:

✅ Lazy Loading
<?php
// ✅ Load only when needed
public function withProfile(): self
{
    if ($this->_profile === null && $this->id) {
        // Load only if not already loaded
        $this->_profile = (new ProfileTable())->selectByUserId($this->id);
    }
    return $this;
}

// ❌ Don't load everything in constructor
public function __construct()
{
    // ❌ Bad - loads everything upfront
    $this->loadAllRelations();
}

💡 Keep Separate

Don't mix database columns with aggregated properties. Keep them clearly separated:

✅ Clear Separation
<?php
class UserModel extends UserTable
{
    // Database columns (no _ prefix)
    public int $id;
    public string $name;
    public string $email;
    
    // Aggregated models (_ prefix)
    public ?ProfileModel $_profile = null;
    public array $_orders = [];
}

Complete Example: UserModel with Multiple Aggregations

Here's a complete example showing how to use the _ prefix convention in a real-world scenario:

app/model/UserModel.php - Complete Example
<?php
namespace App\Model;

use App\Table\UserTable;
use App\Model\ProfileModel;
use App\Model\AddressModel;
use App\Model\OrderModel;

class UserModel extends UserTable
{
    // Database columns
    public int $id;
    public string $name;
    public string $email;
    protected string $password;
    
    // Aggregated models - IGNORED in CRUD operations
    public ?ProfileModel $_profile = null;
    public ?AddressModel $_address = null;
    /** @var array<OrderModel> */
    public array $_orders = [];
    
    /**
     * Load user with profile
     */
    public function withProfile(): self
    {
        if ($this->_profile === null && $this->id) {
            $profileTable = new ProfileTable();
            $this->_profile = $profileTable->selectByUserId($this->id);
        }
        return $this;
    }
    
    /**
     * Load user with address
     */
    public function withAddress(): self
    {
        if ($this->_address === null && $this->id) {
            $addressTable = new AddressTable();
            $this->_address = $addressTable->selectByUserId($this->id);
        }
        return $this;
    }
    
    /**
     * Load user with orders
     */
    public function withOrders(): self
    {
        if (empty($this->_orders) && $this->id) {
            $orderTable = new OrderTable();
            $this->_orders = $orderTable->selectByUserId($this->id);
        }
        return $this;
    }
    
    /**
     * Load all aggregated data
     */
    public function loadAll(): self
    {
        return $this->withProfile()->withAddress()->withOrders();
    }
}

Usage in API Response:

Using in API Layer
<?php
// In Controller or Model
$user = (new UserTable())->selectById($id);
$user->loadAll();  // Loads profile, address, orders

// Return in response
return Response::success([
    'id' => $user->id,
    'name' => $user->name,
    'email' => $user->email,
    'profile' => $user->_profile,      // Aggregated data
    'address' => $user->_address,     // Aggregated data
    'orders' => $user->_orders         // Aggregated data
], 1, 'User retrieved successfully');

Important Notes

  • Complete Ignore: Properties starting with _ are completely ignored in INSERT, UPDATE, and schema generation. They never touch the database.
  • Special Models: You can create models without tables (just PHP classes) for aggregation. They don't need to extend Table.
  • Type Safety: Always add type hints and PHPDoc annotations for PHPStan Level 9 compliance. Use @var array<ModelName> for arrays.
  • Lazy Loading: Load aggregated data only when needed. Don't load everything in the constructor - use helper methods like withProfile().
  • Clear Separation: Keep database columns (no _ prefix) separate from aggregated properties (with _ prefix) for clarity.

🧩 Complex Models Mastered!

Excellent! You've learned how to use GEMVC's powerful _ prefix convention to create complex models with aggregation and composition. Remember: properties starting with _ are completely ignored in CRUD operations!