Hey, Luca here.
I’ve always been fascinated by clean architecture and how to build systems that stay simple yet deliver everything required. Naturally, without going overboard—results are what matter most. No need to hail a taxi just to buy bread.
Over time, certain patterns and principles emerge that resonate with your soul. Everyone has their own: some swear by TDD, others ATDD, FDD, BDD, or whatever-DD. But I’ve grown most attached to DDD, where the first "D" varies: I’m equally obsessed with Domain and Data.
Oh, data, my dear data, how I adore you!
Data is everything. Without data, there’d be no system. What’s the point if we can’t even shuffle around little JSONs?
Data is flexible. It morphs as we add features, refactor, or update API providers—and that’s okay. We must embrace change, not fear it.
Data is communication between systems. Sure, data often lives and dies within a single domain (doesn’t make those cogs less vital). But for cross-system exchanges, why reinvent the wheel? Built-in data types are our cozy mattress.
Data is transformation. When an object spawns a structure of standard types, it should know how to shape it. And vice versa—when ingesting standard types, a class must birth an object by its own rules. Often, third-party code handles this, but ideally, transformation belongs to the data itself. This way, our Data isn’t a passive victim of external tinkering. Data declares, "Here’s how I look naked, without my class wrapper!"
Data must never be ambiguous. Period.
Talk Is Cheap, Let’s Code
To turn this vision into reality, we’ll harness the full power of PHP 8+. We’re about to build a system where every procedure performed on data is defined by the data classes themselves. This means our objects will have an intrinsic ability to serialize and parse themselves without the messy overhead of manual mapping scattered across our codebase.
Serialization & Parsing
We’ll focus on two tasks:
- Serialization: Converting data classes to primitive structures (arrays for objects, scalars otherwise).
- Parsing: Reconstructing objects from primitives (numbers, strings, arrays).
Both methods work on public attributes—those defined in the class body or, even better, via constructor property promotion. I favor constructor promotion because it preserves the possibility of manually creating consistent objects without having to repeat yourself.
Base Structure
Imagine we have an entity that represents a user in our article domain. Here’s how we can set it up:
namespace Domain\Article\Entities;
use Domain\Article\Enums\UserPermission;
use Looqey\Speca\Data;
class User extends Data {
public function __construct(
public string $id,
public string $name,
public array $articles,
public UserPermission $permission,
) {}
}
// ---
namespace Domain\Article\Enums;
enum UserPermission: string {
case READ = 'read';
case CREATE = 'create';
}
The base class, Data
, implements a static from()
method that can construct objects from plain arrays or even from other data classes (after converting them to arrays, of course). This is our elegant solution to parsing. Serialization, on the other hand, is handled by calling the toArray() method on an object or simply leveraging PHP’s native mechanisms (such as json_encode
). Both methods work seamlessly with nested data classes—meaning if you’re assembling a complex aggregate from arrays within arrays, everything will be pieced together correctly.
Transformation Rules
PHP has been blessed with attributes for over five years now, and they’re a godsend when it comes to tagging fields with specific serialization and parsing rules. We’re going to use these attributes to annotate our data classes, making our intentions crystal clear.
For example, let’s define two attributes:
-
#[ParseBy]
: Assigns a transformer for parsing input. -
#[SerializeBy]
: Assigns a transformer for serialization.
The transformers themselves are simple: they take an input value and a property descriptor (which includes the field’s name, type, and contextual info) and return a transformed value. Here’s the interface for a transformer:
namespace Looqey\Speca\Contracts;
use Looqey\Speca\Core\Property;
interface Transformer {
public function transform(mixed $value, Property $property): mixed;
}
A Practical Example: Converting a User to a UUID
Consider a scenario where you have a complex object that contains a related entity. Often, it’s more efficient to transmit just a key—be it a simple identifier or a composite key—so that in another bounded context you can reassemble the full object from that key. Let’s create a transformer for this purpose:
namespace Domain\Article\Transformers;
use Domain\Article\Entities\User;
use Looqey\Data\Property\Property;
class UserToUuidTransformer {
public function transform(mixed $value, Property $property): mixed {
// Assume User always has a UUID in `id`
return $value instanceof User ? $value->id : $value;
}
}
// ---
namespace Domain\Article\Entities;
use Looqey\Attributes\SerializeBy;
class Comment {
public function __construct(
public string $id,
#[SerializeBy(UserToUuidTransformer::class)]
public User $author,
public string $text
) {}
}
When you serialize a Comment
object, the author
field will output the user’s UUID:
$comment = new Comment(
id: 'c1',
author: new User(id: 'u-uuid', name: 'John', articles: [], permission: UserPermission::READ),
text: 'Hello there'
);
$arrayComment = $comment->toArray();
/* Output:
[
'id' => 'c1',
'author' => 'u-uuid',
'text' => 'Hello there'
]
*/
Likewise, for parsing, you might want a transformer that takes a UUID and constructs the corresponding User
object:
namespace OtherDomain\Transformers;
use Looqey\Speca\Contracts\Transformer;
use Looqey\Data\Property\Property;
class UuidToUserTransformer implements Transformer {
public function transform(mixed $value, Property $property): mixed {
// Fetch/create User by UUID for another bounded context
return is_string($value) ? UserRepository::findOrCreate($value) : $value;
}
}
Arrays & Collections
Data classes often encapsulate collections—whether arrays of scalars or arrays of other data objects. To handle such scenarios, we introduce the #[Set]
attribute. This attribute lets you explicitly specify the type of elements within a collection and define custom serialization/parsing rules for each element.
For instance, suppose you want to ensure that article titles are always capitalized:
namespace Domain\Article\Transformers;
use Looqey\Speca\Contracts\Transformer;
use Looqey\Speca\Core\Property;
class TitleUcTransformer implements Transformer {
public function transform(mixed $value, Property $property) {
return is_string($value) ? ucfirst($value) : $value;
}
}
// -----
namespace Domain\Article\Entities;
use Looqey\Speca\Attributes\Set;
use Looqey\Speca\Attributes\ParseBy;
use Looqey\Speca\Data;
class Article extends Data {
public function __construct(
public string $id,
#[ParseBy(TitleUcTransformer::class)]
public string $title
) {}
}
class User extends Data {
public function __construct(
public string $id,
public string $name,
#[Set(of: Article::class)]
public array $articles,
) {}
}
When parsing an array into a User
object, the system automatically converts each element of the articles array into an Article
instance:
$userData = [
'id' => '123',
'name' => 'John Doe',
'articles' => [
['id' => 'some-uuid', 'title' => 'my awesome article']
]
];
$user = User::from($userData);
echo $user->articles[0]->title; // "My awesome article"
Field Name Mapping
Sometimes the external data uses different naming conventions than your internal class properties. To address this, we provide two more attributes:
-
#[ParseFrom]
– Specifies one or more alternative field names to look for during parsing (using a fallback scheme). -
#[SerializeTo]
– Defines the name of the field in the serialized output.
For example, you might want the id field to always appear as user_id
in the output, while still being parsed from either user_id
or id
:
namespace Domain\Article\Entities;
use Looqey\Speca\Attributes\ParseFrom;
use Looqey\Speca\Attributes\SerializeTo;
class User {
public function __construct(
#[ParseFrom('user_id', 'id'), SerializeTo('user_id')]
public string $id,
public string $name,
) {}
}
The Dirty Work: Lazy Evaluation
Not every piece of data needs to be loaded immediately. Sometimes, you want to delay computation until it’s absolutely necessary. For that, we use a lazy wrapper.
Imagine a User
that has a profile which is only fetched on demand:
namespace Domain\Article\Entities;
use Looqey\Data\Type\Lazy;
class User {
public function __construct(
public string $id,
public string $name,
#[Set(of: Article::class)]
public array $articles,
public Lazy|Profile $profile // Loaded on-demand
) {}
}
// Usage:
$user = User::from([
"id" => $myUserID,
"name" => "John Doe",
"articles" => [],
"profile" => new Lazy(fn () => $profileRepo->get($myUserID))
]);
// Exclude sensitive data or include nested fields:
$user->include("profile");
$user->exclude("some-sensitive-data"); // Supports dot-notation (e.g., 'details.billing')
Conclusion
Everything described above is a distilled version of my thought process that gave birth to the Speca [spéka]
library. The idea is to create a clean, universal solution where data itself defines how it’s serialized and parsed—eliminating the tedious, error-prone manual mapping scattered throughout your codebase.
Why Speca rocks:
- Eliminates Boilerplate: Instead of manually mapping and converting data in multiple places, you have a single, consistent method defined by your classes.
- Flexible Customization: You can assemble any variation of field transformation and serialization rules (even chaining transformers if you’re feeling extra creative).
- Lazy Loading & Include/Exclude: Provides granular control over which properties are computed or exposed, optimizing performance and data privacy.
Use cases:
- Cross-Bounded Context Communication: When transferring objects between different contexts, Speca streamlines the mapping process, saving you from a mountain of manual transformations.
- Large Monoliths: It helps manage data exchanges between modules that require slightly different views of the same entity.
- API DTOs: It’s perfect for building data transfer objects that shape your external API responses.
If you’re intrigued and want to dive deeper, check out the Speca library on GitHub and browse through the official docs.
Keep your data alive, and your code alive-er.
— Luca
Top comments (0)