Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
*.php text eol=lf
*.phpt text eol=lf
/.gitattributes export-ignore
/.github/ export-ignore
/.gitignore export-ignore
/composer.lock export-ignore
/docs/ export-ignore
/phpbench.json export-ignore
/phpcs.xml.dist export-ignore
/phpstan.neon export-ignore
/phpunit.xml.dist export-ignore
/tests/ export-ignore
/tools/ export-ignore
48 changes: 48 additions & 0 deletions .github/workflows/docs-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# https://help.github.com/en/categories/automating-your-workflow-with-github-actions

name: "Check Docs"

on:
pull_request:
push:
branches:
- "[0-9]+.[0-9]+.x"

jobs:
checkdocs:
name: "Check Docs"

runs-on: ${{ matrix.operating-system }}

strategy:
matrix:
dependencies:
- "locked"
php-version:
- "8.4"
operating-system:
- "ubuntu-latest"

steps:
- name: "Checkout"
uses: actions/checkout@v6

- name: "Install PHP"
uses: "shivammathur/setup-php@2.37.2"
with:
coverage: none
php-version: "${{ matrix.php-version }}"
ini-values: memory_limit=-1, opcache.enable_cli=1

- uses: ramsey/composer-install@4.0.0
with:
dependency-versions: ${{ matrix.dependencies }}

- name: "extract php code"
run: "bin/docs-extract-php-code"

- name: "lint php"
run: "php -l docs_php/*.php"

- name: "docs code style"
run: "vendor/bin/phpcbf docs_php --exclude=SlevomatCodingStandard.TypeHints.DeclareStrictTypes,SlevomatCodingStandard.ControlStructures.EarlyExit"
22 changes: 22 additions & 0 deletions .github/workflows/docs-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Publish docs

on:
push:
branches:
- "[0-9]+.[0-9]+.x"
release:
types:
- published

jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: Trigger workflow in other repo
run: |
curl -L -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.ORGANIZATION_ADMIN_TOKEN }}" \
-H "X-GitHub-Api-Version: 2026-03-10" \
https://api.github.com/repos/patchlevel/patchlevel.dev/actions/workflows/prod-deployment.yaml/dispatches \
-d '{"ref":"main"}'
22 changes: 22 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,25 @@ benchmark-diff-test: vendor

.PHONY: dev
dev: static test ## run dev tools

.PHONY: docs
docs: docs-extract-php docs-php-lint docs-phpcs docs-inject-php

.PHONY: docs-extract-php
docs-extract-php:
bin/docs-extract-php-code

.PHONY: docs-inject-php
docs-inject-php:
bin/docs-inject-php-code

.PHONY: docs-format ## format docs
docs-format: docs-phpcs docs-inject-php

.PHONY: docs-php-lint ## lint docs code
docs-php-lint: docs-extract-php
php -l docs_php/*.php | grep 'Parse error: ' || true

.PHONY: docs-phpcs
docs-phpcs: docs-extract-php
vendor/bin/phpcbf docs_php --exclude=SlevomatCodingStandard.TypeHints.DeclareStrictTypes,SlevomatCodingStandard.ControlStructures.EarlyExit || true
170 changes: 26 additions & 144 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,160 +1,42 @@
# Patchlevel ODM

Patchlevel ODM is a lightweight **Object Document Mapper (ODM)** for PHP that works with **MongoDB** and **PostgreSQL (via [patchlevel/rango](https://github.com/patchlevel/rango/))**.
It is built on top of our superfast **[patchlevel/hydrator](https://github.com/patchlevel/hydrator/)**, providing a simple attribute-based mapping layer and
enterprise-grade features like cyptography.

## 🚀 Why Patchlevel ODM?
[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fpatchlevel%2Fodm%2F1.0.x)](https://dashboard.stryker-mutator.io/reports/github.com/patchlevel/odm/1.0.x)
[![Latest Stable Version](https://poser.pugx.org/patchlevel/odm/v)](//packagist.org/packages/patchlevel/odm)
[![License](https://poser.pugx.org/patchlevel/odm/license)](//packagist.org/packages/patchlevel/odm)

* **Postgres and MongoDB support** – Use the same ODM for both Postgres and MongoDB, with a consistent API.
* **Attribute-based Mapping** – Define documents and indexes using modern PHP attributes.
* **No Unit of Work** – Patchlevel ODM does not use a Unit of Work, giving you more control over when changes are persisted.
* **Built on Patchlevel Hydrator** – Benefit from the performance and extensibility (like [crypto shredding](https://github.com/patchlevel/hydrator/#cryptography)) of our powerful [hydrator library](https://github.com/patchlevel/hydrator/).
# Patchlevel ODM

## 📦 Installation
Patchlevel ODM is a lightweight **Object Document Mapper (ODM)** for PHP that works with **MongoDB** and **PostgreSQL** (via [patchlevel/rango](https://github.com/patchlevel/rango/)). It is built on top of our superfast **[patchlevel/hydrator](https://github.com/patchlevel/hydrator/)**, providing a simple attribute-based mapping layer and enterprise-grade features like cryptography.

You can install Patchlevel ODM using Composer.
Depending on your database choice, you will need to require the appropriate packages.
Unlike Doctrine ODM, Patchlevel ODM has **no Unit of Work**. Repositories control persistence explicitly, so every write is deliberate and easy to reason about, which makes the library a good fit for long-running worker processes.

### PostgreSQL (via [Rango](https://github.com/patchlevel/rango/))
## Features

```bash
composer require patchlevel/odm patchlevel/rango
```
* [MongoDB and PostgreSQL support](https://patchlevel.dev/docs/odm/latest/databases) with a single, consistent API
* [Attribute-based document mapping](https://patchlevel.dev/docs/odm/latest/documents) with `#[Document]` and `#[Id]`
* [Repositories without a Unit of Work](https://patchlevel.dev/docs/odm/latest/repository) for predictable writes
* [Querying](https://patchlevel.dev/docs/odm/latest/repository#querying) with filters, sorting and pagination
* [Indexes](https://patchlevel.dev/docs/odm/latest/documents#indexes) defined with `#[Index]`, including unique constraints
* [Field mapping and normalization](https://patchlevel.dev/docs/odm/latest/field-mapping) for nested objects and custom field names
* [Encryption and crypto shredding](https://patchlevel.dev/docs/odm/latest/encryption) for sensitive data

### MongoDB
## Installation

```bash
composer require patchlevel/odm mongodb/mongodb
composer require patchlevel/odm
```

## 🛠 How it Works

Patchlevel ODM maps PHP objects to document storage.

* **Documents** are defined using the `#[Document]` attribute.
* **Identifiers** are declared with `#[Id]`.
* **Indexes** can be defined using `#[Index]`.
* **Repositories** provide a simple API for loading and storing documents.

Internally the ODM uses:

* **[patchlevel/rango](https://github.com/patchlevel/rango/)** as the database abstraction layer for PostgreSQL
* **[mongodb/mongodb](https://github.com/mongodb/mongo-php-library)** as the database abstraction layer for MongoDB
* **[patchlevel/hydrator](https://github.com/patchlevel/hydrator/)** for object mapping and normalization

## 🚦 Quick Start

Define your documents and indexes using PHP attributes.

```php
use Patchlevel\ODM\Attribute\Document;
use Patchlevel\ODM\Attribute\Id;
use Patchlevel\ODM\Attribute\Index;

#[Document('profiles')]
#[Index('by_status', ['status' => 'asc'])]
final class Profile
{
/** @param list<Skill> $skills */
public function __construct(
#[Id]
public readonly string $id,
public string $name,
public Status $status,
public array $skills,
) {
}
}

final readonly class Skill
{
public function __construct(
public string $value,
) {
}
}

enum Status: string
{
case ACTIVE = 'active';
case INACTIVE = 'inactive';
}
```

### Setup PostgreSQL (via [Rango](https://github.com/patchlevel/rango/))

```php
use Patchlevel\ODM\Repository\RangoRepositoryManager;
use Patchlevel\Rango\Client;

$client = new Client($_ENV['POSTGRES_URI']);

$manager = RangoRepositoryManager::create($client);
```

### Setup MongoDB

```php
use MongoDB\Client;
use Patchlevel\ODM\Repository\MongoDBRepositoryManager;

$client = new Client($_ENV['MONGODB_URI']);

$manager = MongoDBRepositoryManager::create($client);
```

### Usage

Now you can use the repository manager to access your documents.

```php
$repository = $manager->get(Profile::class);

$repository->insert(
new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]),
new Profile('r-2', 'Foo', Status::ACTIVE, [new Skill('node'), new Skill('js')]),
new Profile('r-3', 'Bar', Status::INACTIVE, [new Skill('mongodb')]),
);

$profiles = $repository->findBy(
filter: ['status' => Status::ACTIVE->value],
sort: ['name' => 'asc'],
limit: 10,
offset: 0
);

$profile = $repository->get('r-2');
$profile->name = 'New Foo';
$repository->update($profile);

$repository->remove('r-3');
```

## 🏗️ Design differences compared to Doctrine ODM

Doctrine ODM has a feature that we don't want: a Unit of Work (UOW).
A UOW tracks new objects and changes to existing objects.
To commit changes to the database, you need to call `flush()`.
In this step, the Unit of Work calculates the changes and applies them to the database.
## Documentation

This approach has several side effects:
* Latest [Docs](https://patchlevel.dev/docs/odm/latest)
* Related [Blog](https://patchlevel.dev/blog)

- **Growing memory usage in long-running workers**
Since the Unit of Work keeps references to managed documents, memory usage can continuously grow in worker processes
unless documents are manually detached or the UOW is cleared.
## Integration

- **Risk of unintentionally persisting changes**
Because documents are tracked automatically, changes to a document may be persisted later by a `flush()` call in a
completely different part of the codebase.
* [patchlevel/hydrator](https://github.com/patchlevel/hydrator)
* [patchlevel/rango](https://github.com/patchlevel/rango)

- **Risk of stale data**
Managed documents may become outdated if they remain in the UOW for too long, especially in long-running processes.
## Contributing

- **Complex lifecycle management**
To avoid these issues, developers often need to call `clear()` or `detach()`, which adds complexity and can easily
introduce subtle bugs.
We are open to contributions as long as they are in line with
our [BC-Policy](https://patchlevel.dev/our-backward-compatibility-promise).

Because of these trade-offs, we intentionally avoid using a Unit of Work. Instead, repositories explicitly control
persistence operations. This means every insert or update must be triggered deliberately, making database writes
predictable and easier to reason about.
Also note that the `composer.lock` is always generated with the newest supported PHP version as this is the version our tools run in the CI.
53 changes: 53 additions & 0 deletions bin/docs-extract-php-code
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env php
<?php

use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
use League\CommonMark\Node\Query;
use League\CommonMark\Parser\MarkdownParser;
use Wnx\CommonmarkMarkdownRenderer\MarkdownRendererExtension;

require __DIR__ . '/../vendor/autoload.php';

$environment = new Environment([]);
$environment->addExtension(new MarkdownRendererExtension());

$parser = new MarkdownParser($environment);

$targetDir = __DIR__ . '/../docs_php';

if (file_exists($targetDir)) {
exec('rm -rf ' . $targetDir);
}

mkdir($targetDir);

$finder = new Symfony\Component\Finder\Finder();
$finder->files()->in(__DIR__ . '/../docs')->name('*.md');

foreach ($finder as $file) {
$fileName = pathinfo($file->getBasename(), PATHINFO_FILENAME);

$content = file_get_contents($file->getPathname());
$document = $parser->parse($content);

$result = (new Query())
->where(Query::type(FencedCode::class))
->findAll($document);

/**
* @var FencedCode $node
*/
foreach ($result as $i => $node) {
if ($node->getInfo() !== 'php') {
continue;
}

$source = sprintf('%s:%s', $file->getRealPath(), $node->getStartLine());

$code = "<?php\n// " . $source . "\n\n" . $node->getLiteral();

$targetPath = $targetDir . '/' . $fileName . '_' . $i . '.php';
file_put_contents($targetPath, $code);
}
}
Loading
Loading