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


README

Latest Version on Packagist Total Downloads PHP Version License: MIT GitHub last commit wakatime

Tests Static Analysis Code Style Coveralls

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->name and $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 SerializationConvention attribute
  • 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

๐ŸŽฏ 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