diego-ninja / granite
A lightweight zero-dependency PHP library for building immutable, serializable objects with validation capabilities.
Installs: 702
Dependents: 1
Suggesters: 0
Security: 0
Stars: 58
Watchers: 3
Forks: 0
Open Issues: 0
pkg:composer/diego-ninja/granite
Requires
- php: ^8.3|^8.4
Requires (Dev)
- fakerphp/faker: ^1.23
- laravel/pint: ^1.24
- mockery/mockery: ^1.6
- nesbot/carbon: ^3.10
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11.0
- rector/rector: ^2.0
- squizlabs/php_codesniffer: ^3.7
- dev-main
- v1.5.1
- v1.5.0
- v1.4.1
- v1.4.0
- v1.3.5
- v1.3.4
- v1.3.3
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.2
- v1.2.0
- v1.1.0
- v1.0.0
- dev-hotfix/coderabbit_review
- dev-coderabbitai/docstrings/00ed8aa
- dev-feature/peeble_immutable_dto
- dev-feature/hydration_from_plain_object
- dev-feature/comparation_capabilities
- dev-feat/support-default-enum-for-unexpected-values
- dev-feat/deserialization/support-default-case
- dev-feature/maybe_option_monads
This package is auto-updated.
Last update: 2025-10-28 17:06:56 UTC
README
A powerful, zero-dependency PHP library for building immutable, serializable objects with validation and mapping capabilities. Perfect for DTOs, Value Objects, API responses, and domain modeling.
๐ชถ Pebble - NEW! Lightweight Immutable Snapshots
Need a quick immutable snapshot without validation overhead? Meet Pebble - a lightweight alternative perfect for:
- Creating immutable snapshots of Eloquent models
- Fast DTOs without validation or type definitions
- Caching and comparison use cases
- High-performance object comparisons with fingerprinting
use Ninja\Granite\Pebble; // Create an immutable snapshot from any object $user = User::query()->first(); $snapshot = Pebble::from($user); // Access properties dynamically (both styles work!) echo $snapshot->name; // Magic __get echo $snapshot['email']; // ArrayAccess interface $count = count($snapshot); // Countable interface // Fast O(1) comparisons with fingerprinting $snapshot1 = Pebble::from($user1); $snapshot2 = Pebble::from($user2); if ($snapshot1->equals($snapshot2)) { // Uses cached hash for instant comparison! } // Get unique fingerprint for caching/comparison $hash = $snapshot->fingerprint(); // xxh3 hash // Immutable transformations $publicData = $snapshot->except(['password'])->merge(['status' => 'active']);
Key Features:
- โก Fast comparisons - O(1) equality checks using xxh3 fingerprinting
- ๐ ArrayAccess - Use both
$pebble->nameand$pebble['name']syntax - ๐ Countable - Native
count($pebble)support - ๐ Immutable - Cannot be modified after creation
- ๐ Zero overhead - No validation or type checking
When to use which:
- Pebble โ Quick snapshots, no validation needed, dynamic properties, maximum performance, fast comparisons
- Granite โ Full DTOs with validation, type safety, custom serialization, advanced features
๐ Read Pebble Documentation
This documentation has been generated almost in its entirety using ๐ฆ Claude 4 Sonnet based on source code analysis. Some sections may be incomplete, outdated or may contain documentation for planned or not-released features. For the most accurate information, please refer to the source code or open an issue on the package repository.
โจ Features
๐ Immutable Objects
- Read-only DTOs and Value Objects
- Thread-safe by design
- Functional programming friendly
๐ฏ Enhanced Object Creation
- Multiple
from()patterns - Array, JSON, named parameters, mixed usage - Transparent operation - No method overrides needed in child classes
- Type-safe - Full PHPStan Level 10 compatibility
- Flexible usage - Perfect for APIs, domain modeling, and clean code
โ Comprehensive Validation
- 30+ built-in validation rules including Carbon date validation
- Attribute-based validation (PHP 8+)
- Custom validation rules and callbacks
- Conditional and nested validation
- Carbon-specific rules - Age, BusinessDay, Future, Past, Range, Weekend
๐ Powerful ObjectMapper
- Automatic property mapping between objects
- Convention-based mapping with multiple naming conventions
- Custom transformations and collection mapping
- Bidirectional mapping support
๐ฆ Smart Serialization
- JSON/Array serialization with custom property names
- Class-level naming conventions with
SerializationConventionattribute - Hide sensitive properties automatically
- Carbon date handling - Custom formats, relative parsing, timezone support
- DateTime and Enum handling
- Nested object serialization
๐ Object Comparison
- Deep equality comparison with
equals()method - Detailed difference detection with
differs()method - Recursive comparison of nested objects and arrays
- Timezone-aware DateTime comparison
- Efficient array comparison without JSON encoding
โก Performance Optimized
- Reflection caching for improved performance
- Memory-efficient object creation
- Lazy loading support
๐ Quick Start
Installation
composer require diego-ninja/granite
Basic Usage
<?php use Ninja\Granite\Granite; use Ninja\Granite\Validation\Attributes\Required; use Ninja\Granite\Validation\Attributes\Email; use Ninja\Granite\Validation\Attributes\Min; use Ninja\Granite\Serialization\Attributes\SerializedName; use Ninja\Granite\Serialization\Attributes\SerializationConvention; use Ninja\Granite\Serialization\Attributes\Hidden; use Ninja\Granite\Serialization\Attributes\CarbonDate; use Carbon\Carbon; // Create a Granite object with validation final readonly class User extends Granite { public function __construct( public ?int $id, #[Required] #[Min(2)] public string $name, #[Required] #[Email] public string $email, #[Hidden] // Won't appear in JSON public ?string $password = null, #[SerializedName('created_at')] #[CarbonDate(format: 'Y-m-d H:i:s')] public Carbon $createdAt = new Carbon() ) {} } // Multiple ways to create objects - all work transparently! // 1. From array (traditional) $user = User::from([ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'secret123' ]); // 2. From JSON string $user = User::from('{"name": "John Doe", "email": "john@example.com", "password": "secret123"}'); // 3. Named parameters (NEW!) - Works transparently without method overrides $user = User::from( name: 'John Doe', email: 'john@example.com', password: 'secret123' ); // 4. Mixed - base data with named overrides (NEW!) $baseData = ['name' => 'John', 'email' => 'john@example.com']; $user = User::from($baseData, name: 'John Doe', password: 'secret123'); // 5. From another Granite object $anotherUser = User::from($user); // Immutable updates $updatedUser = $user->with(['name' => 'Jane Doe']); // Serialization with Carbon support $json = $user->json(); // {"id":null,"name":"John Doe","email":"john@example.com","created_at":"2024-01-15 10:30:00"} $array = $user->array(); // password is hidden, created_at uses custom Carbon format
๐ฏ Enhanced from() Method
Granite's from() method supports multiple invocation patterns transparently - no need to override methods in child classes!
final readonly class Product extends Granite { public function __construct( public string $name, public float $price, public ?string $description = null, #[CarbonDate] public Carbon $createdAt = new Carbon() ) {} } // All these patterns work automatically: // Array data $product = Product::from(['name' => 'Laptop', 'price' => 999.99]); // JSON string $product = Product::from('{"name": "Laptop", "price": 999.99}'); // Named parameters - perfect for APIs and clean code $product = Product::from( name: 'Laptop', price: 999.99, description: 'High-performance laptop' ); // Mixed patterns - base data + overrides $defaults = ['name' => 'Generic Product', 'price' => 0.0]; $product = Product::from($defaults, name: 'Laptop', price: 999.99); // From another Granite object $clonedProduct = Product::from($product); // Partial data - unspecified properties remain uninitialized $partial = Product::from(name: 'Laptop', price: 999.99); // $partial->description is uninitialized, not null
๐ Carbon Date Support
Granite includes comprehensive support for Carbon dates with specialized attributes:
use Ninja\Granite\Serialization\Attributes\CarbonDate; use Ninja\Granite\Serialization\Attributes\CarbonRange; use Ninja\Granite\Serialization\Attributes\CarbonRelative; use Ninja\Granite\Validation\Rules\Carbon\Age; use Ninja\Granite\Validation\Rules\Carbon\Future; use Ninja\Granite\Validation\Rules\Carbon\BusinessDay; final readonly class Event extends Granite { public function __construct( public string $title, // Custom date format for serialization #[CarbonDate(format: 'd/m/Y H:i')] public Carbon $startDate, // Date range validation #[CarbonRange(min: 'now', max: '+1 year')] public Carbon $endDate, // Relative date parsing ('tomorrow', '2 weeks ago', etc.) #[CarbonRelative] public ?Carbon $reminderDate = null, // Business logic validation #[Future(message: 'Event must be in the future')] #[BusinessDay(message: 'Event must be on a business day')] public Carbon $publishDate ) {} } // Create with various date formats $event = Event::from( title: 'Conference', startDate: '2024-12-25 09:00:00', // Standard format endDate: Carbon::parse('+3 days'), // Carbon object reminderDate: 'tomorrow at 9am', // Relative format publishDate: '2024-12-01' // Date only ); // Serialization uses custom formats $json = $event->json(); // {"title":"Conference","startDate":"25/12/2024 09:00",...}
Serialization Conventions
Apply naming conventions to all properties in a class during serialization:
use Ninja\Granite\Mapping\Conventions\SnakeCaseConvention; use Ninja\Granite\Serialization\Attributes\SerializationConvention; #[SerializationConvention(SnakeCaseConvention::class)] final readonly class UserProfile extends Granite { public function __construct( public string $firstName, // serialized as "first_name" public string $lastName, // serialized as "last_name" public string $emailAddress, // serialized as "email_address" #[SerializedName('user_id')] // explicit name takes precedence public int $id ) {} } $profile = new UserProfile('John', 'Doe', 'john@example.com', 123); $json = $profile->json(); // {"first_name":"John","last_name":"Doe","email_address":"john@example.com","user_id":123}
๐ Object Comparison
Compare Granite objects for equality or detect specific differences:
use Ninja\Granite\Granite; final readonly class User extends Granite { public function __construct( public int $id, public string $name, public string $email, public ?DateTime $lastLogin = null ) {} } // Create two user instances $user1 = User::from(['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com']); $user2 = User::from(['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com']); $user3 = User::from(['id' => 1, 'name' => 'Jane Doe', 'email' => 'jane@example.com']); // Check equality $user1->equals($user2); // true - all properties match $user1->equals($user3); // false - name and email differ // Get detailed differences $differences = $user1->differs($user3); // [ // 'name' => ['current' => 'John Doe', 'new' => 'Jane Doe'], // 'email' => ['current' => 'john@example.com', 'new' => 'jane@example.com'] // ] // Works with nested objects $post1 = Post::from([ 'title' => 'My Post', 'author' => User::from(['id' => 1, 'name' => 'John', 'email' => 'john@example.com']) ]); $post2 = Post::from([ 'title' => 'My Post', 'author' => User::from(['id' => 2, 'name' => 'Jane', 'email' => 'jane@example.com']) ]); $differences = $post1->differs($post2); // [ // 'author' => [ // 'id' => ['current' => 1, 'new' => 2], // 'name' => ['current' => 'John', 'new' => 'Jane'], // 'email' => ['current' => 'john@example.com', 'new' => 'jane@example.com'] // ] // ]
๐ Documentation
Core Concepts
- Enhanced from() Method - Multiple invocation patterns for flexible object creation โจ NEW
- Validation - Comprehensive validation system with 30+ built-in rules including Carbon
- Serialization - Control how objects are converted to/from arrays and JSON with Carbon support
- Object Comparison - Deep equality checks and difference detection โจ NEW
- ObjectMapper - Powerful object-to-object mapping with conventions
- Advanced Usage - Patterns for complex applications
- API Reference - Complete API documentation with new Carbon features
Guides
- Migration Guide - Migrate from arrays, stdClass, Doctrine, Laravel
- Troubleshooting - Common issues and solutions
๐ฏ Use Cases
API Development
// Request validation final readonly class CreateUserRequest extends Granite { public function __construct( #[Required] #[StringType] #[Min(2)] public string $name, #[Required] #[Email] public string $email, #[Required] #[Min(8)] #[Regex('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/', 'Password must contain uppercase, lowercase, and number')] public string $password ) {} } // API Response final readonly class UserResponse extends Granite { public function __construct( public int $id, public string $name, public string $email, #[SerializedName('member_since')] public DateTime $createdAt ) {} public static function fromEntity(User $user): self { return new self( id: $user->id, name: $user->name, email: $user->email, createdAt: $user->createdAt ); } }
Domain Modeling
// Value Objects final readonly class Money extends Granite { public function __construct( #[Required] #[NumberType] #[Min(0)] public float $amount, #[Required] #[StringType] #[Min(3)] #[Max(3)] public string $currency ) {} public function add(Money $other): Money { if ($this->currency !== $other->currency) { throw new InvalidArgumentException('Cannot add different currencies'); } return new Money($this->amount + $other->amount, $this->currency); } } // Aggregates final readonly class Order extends Granite { public function __construct( public ?int $id, #[Required] public int $customerId, #[Required] #[ArrayType] #[Each(new Rules\Callback(fn($item) => OrderItem::from($item)))] public array $items, #[Required] public OrderStatus $status = OrderStatus::PENDING ) {} public function getTotal(): Money { $total = new Money(0.0, 'USD'); foreach ($this->items as $item) { $total = $total->add($item->getTotal()); } return $total; } }
Object Mapping with ObjectMapper
use Ninja\Granite\Mapping\ObjectMapper; use Ninja\Granite\Mapping\ObjectMapperConfig; use Ninja\Granite\Mapping\Attributes\MapFrom; // Source entity final readonly class UserEntity extends Granite { public function __construct( public int $userId, public string $fullName, public string $emailAddress, public DateTime $createdAt ) {} } // Destination DTO with mapping attributes final readonly class UserSummary extends Granite { public function __construct( #[MapFrom('userId')] public int $id, #[MapFrom('fullName')] public string $name, #[MapFrom('emailAddress')] public string $email ) {} } // Create ObjectMapper with clean configuration $mapper = new ObjectMapper( ObjectMapperConfig::forProduction() ->withConventions(true, 0.8) ->withSharedCache() ); // Automatic mapping $summary = $mapper->map($userEntity, UserSummary::class);
๐ฅ Advanced Features
Convention-Based Mapping
// Automatically maps between different naming conventions class SourceClass { public string $firstName; // camelCase public string $email_address; // snake_case public string $UserID; // PascalCase } class DestinationClass { public string $first_name; // snake_case public string $emailAddress; // camelCase public string $user_id; // snake_case } $mapper = new ObjectMapper( ObjectMapperConfig::create() ->withConventions(true, 0.8) ); $result = $mapper->map($source, DestinationClass::class); // Properties automatically mapped based on naming conventions!
Advanced ObjectMapper Configuration
use Ninja\Granite\Mapping\ObjectMapperConfig; use Ninja\Granite\Mapping\MappingProfile; // Fluent configuration with builder pattern $mapper = new ObjectMapper( ObjectMapperConfig::forProduction() ->withSharedCache() ->withConventions(true, 0.8) ->withProfile(new UserMappingProfile()) ->withProfile(new ProductMappingProfile()) ->withWarmup() ); // Predefined configurations $devMapper = new ObjectMapper(ObjectMapperConfig::forDevelopment()); $prodMapper = new ObjectMapper(ObjectMapperConfig::forProduction()); $testMapper = new ObjectMapper(ObjectMapperConfig::forTesting());
Custom Mapping Profiles
use Ninja\Granite\Mapping\MappingProfile; class UserMappingProfile extends MappingProfile { protected function configure(): void { // Complex transformations $this->createMap(UserEntity::class, UserResponse::class) ->forMember('fullName', fn($m) => $m->using(function($value, $sourceData) { return $sourceData['firstName'] . ' ' . $sourceData['lastName']; }) ) ->forMember('age', fn($m) => $m->mapFrom('birthDate') ->using(fn($birthDate) => (new DateTime())->diff($birthDate)->y) ) ->forMember('isActive', fn($m) => $m->mapFrom('status') ->using(fn($status) => $status === 'active') ) ->seal(); // Bidirectional mapping $this->createMapBidirectional(UserEntity::class, UserDto::class) ->forMembers('userId', 'id') ->forMembers('fullName', 'name') ->forMembers('emailAddress', 'email') ->seal(); } }
Complex Validation
final readonly class CreditCard extends Granite { public function __construct( #[Required] #[Regex('/^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/', 'Invalid card number format')] public string $number, #[Required] #[Regex('/^(0[1-9]|1[0-2])\/([0-9]{2})$/', 'Invalid expiry format (MM/YY)')] public string $expiry, #[Required] #[Regex('/^\d{3,4}$/', 'Invalid CVV')] public string $cvv, #[When( condition: fn($value, $data) => $data['type'] === 'business', rule: new Required() )] public ?string $companyName = null ) {} protected static function rules(): array { return [ 'number' => [ new Callback(function($number) { // Luhn algorithm validation return $this->isValidLuhn(str_replace(' ', '', $number)); }, 'Invalid credit card number') ] ]; } }
Global ObjectMapper Configuration
// Configure once at application startup ObjectMapper::configure(function(ObjectMapperConfig $config) { $config->withSharedCache() ->withConventions(true, 0.8) ->withProfiles([ new UserMappingProfile(), new ProductMappingProfile(), new OrderMappingProfile() ]) ->withWarmup(); }); // Use anywhere in your application $mapper = ObjectMapper::getInstance(); $userDto = $mapper->map($userEntity, UserDto::class);
๐ Validation Rules
Built-in Rules
| Rule | Description | Example |
|---|---|---|
#[Required] |
Field must not be null | #[Required('Name is required')] |
#[Email] |
Valid email format | #[Email('Invalid email')] |
#[Min(5)] |
Minimum length/value | #[Min(5, 'Too short')] |
#[Max(100)] |
Maximum length/value | #[Max(100, 'Too long')] |
#[Regex('/pattern/')] |
Regular expression | #[Regex('/^\d+$/', 'Numbers only')] |
#[In(['a', 'b'])] |
Value in list | #[In(['active', 'inactive'])] |
#[Url] |
Valid URL format | #[Url('Invalid URL')] |
#[IpAddress] |
Valid IP address | #[IpAddress('Invalid IP')] |
#[StringType] |
Must be string | #[StringType] |
#[IntegerType] |
Must be integer | #[IntegerType] |
#[NumberType] |
Must be number | #[NumberType] |
#[BooleanType] |
Must be boolean | #[BooleanType] |
#[ArrayType] |
Must be array | #[ArrayType] |
#[EnumType] |
Valid enum value | #[EnumType(Status::class)] |
#[Each(...)] |
Validate array items | #[Each(new Email())] |
#[When(...)] |
Conditional validation | #[When($condition, $rule)] |
๐ Carbon Date Validation Rules
| Rule | Description | Example |
|---|---|---|
#[Age(min: 18, max: 65)] |
Validate age range | #[Age(min: 18, message: 'Must be adult')] |
#[BusinessDay] |
Must be a business day | #[BusinessDay('Only weekdays allowed')] |
#[Future] |
Date must be in the future | #[Future('Event must be upcoming')] |
#[Past] |
Date must be in the past | #[Past('Birth date must be past')] |
#[Range(min: 'now', max: '+1 year')] |
Date within range | #[Range(min: 'today', max: 'next month')] |
#[Weekend] |
Must be weekend | #[Weekend('Event only on weekends')] |
๐ Performance
Granite is optimized for performance with:
- Reflection caching - Class metadata cached automatically
- Mapping cache - ObjectMapper configurations cached
- Memory efficiency - Immutable objects reduce memory overhead
- Lazy loading - Load related data only when needed
- Specialized components - Refactored architecture with focused responsibilities
// Use shared cache for web applications $mapper = new ObjectMapper( ObjectMapperConfig::forProduction() ->withSharedCache() ->withWarmup() // Preload configurations ); // Preload mappings for better performance use Ninja\Granite\Mapping\MappingPreloader; MappingPreloader::preload($mapper, [ [UserEntity::class, UserResponse::class], [ProductEntity::class, ProductResponse::class] ]);
๐งช Testing
Granite objects are perfect for testing due to their immutability and validation:
class UserTest extends PHPUnit\Framework\TestCase { public function testUserCreation(): void { $user = User::from([ 'name' => 'John Doe', 'email' => 'john@example.com' ]); $this->assertEquals('John Doe', $user->name); $this->assertEquals('john@example.com', $user->email); } public function testObjectMapping(): void { $mapper = new ObjectMapper(ObjectMapperConfig::forTesting()); $entity = new UserEntity(1, 'John Doe', 'john@example.com', new DateTime()); $dto = $mapper->map($entity, UserDto::class); $this->assertInstanceOf(UserDto::class, $dto); $this->assertEquals(1, $dto->id); } public function testImmutability(): void { $user = User::from(['name' => 'John', 'email' => 'john@example.com']); $updated = $user->with(['name' => 'Jane']); // Original unchanged $this->assertEquals('John', $user->name); // New instance created $this->assertEquals('Jane', $updated->name); } }
โ ๏ธ Deprecation Notice
Important: As of version 2.0.0, GraniteDTO and GraniteVO are deprecated in favor of the unified Granite base class.
- โ Deprecated:
Ninja\Granite\GraniteDTO(will be removed in v3.0.0) - โ Deprecated:
Ninja\Granite\GraniteVO(will be removed in v3.0.0) - โ
Use instead:
Ninja\Granite\Granite
Migration:
// โ Old (deprecated) final readonly class User extends GraniteVO { } final readonly class UserDTO extends GraniteDTO { } // โ New (recommended) final readonly class User extends Granite { }
Both deprecated classes currently extend Granite for backward compatibility, so your existing code will continue to work. However, please migrate to Granite before version 3.0.0.
๐ง Requirements
- PHP 8.3+ - Takes advantage of modern PHP features
- No dependencies - Zero external dependencies for maximum compatibility
๐ฆ Installation & Setup
# Install via Composer composer require diego-ninja/granite # Optional: Configure cache directory for persistent mapping cache mkdir cache/granite chmod 755 cache/granite
๐ค Contributing
Contributions are welcome! Please see CONTRIBUTING.md for details.
๐ License
This package is open-sourced software licensed under the MIT license.
๐ Credits
This project is developed and maintained by ๐ฅท Diego Rin in his free time.
If you find this project useful, please consider:
- โญ Starring the repository
- ๐ Reporting bugs and issues
- ๐ก Suggesting new features
- ๐ง Contributing code improvements
Made with โค๏ธ for the PHP community