Complex Models
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:
- ❌
INSERToperations - ❌
UPDATEoperations - ❌ 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
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)
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:
<?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:
<?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:
<?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'
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:
<?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:
<?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
}
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:
<?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:
<?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
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)
<?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)
<?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
<?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;
}
}
Common Patterns
Patterns for Different Use Cases
Here are common patterns for using the _ prefix convention:
Pattern 1: Single Model Aggregation
<?php
// Single related model
public ?ProfileModel $_profile = null;
// Usage
$user->_profile = $profile;
$user->insertSingleQuery(); // Profile ignored
Pattern 2: Array Aggregation
<?php
// Array of related models
public array $_orders = [];
// Usage
$user->_orders = [$order1, $order2];
$user->insertSingleQuery(); // Orders ignored
Pattern 3: Private with Getter
<?php
// Private aggregation with getter
private array $_reviews = [];
public function getReviews(): array
{
return $this->_reviews;
}
Pattern 4: Protected Aggregation
<?php
// Protected aggregation
protected ?CategoryModel $_category = null;
// Can only be accessed within the class or subclasses
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
<?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
<?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
<?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:
<?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:
<?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:
<?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:
<?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:
<?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:
<?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:
<?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!