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
1 change: 0 additions & 1 deletion .php-cs-fixer.cache

This file was deleted.

1 change: 0 additions & 1 deletion .phpunit.result.cache

This file was deleted.

3 changes: 1 addition & 2 deletions docs/source/bundles/intent-bundle/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,6 @@ available for dependency injection. Inject it using the interface:
use App\Security\Intent\ResetPassword;
use RunOpenCode\Component\Intent\Contract\IntentStorageInterface;
use RunOpenCode\Component\Intent\Exception\NotExistsException;
use Symfony\Component\Uid\Ulid;

final readonly class PasswordResetService
{
Expand All @@ -214,7 +213,7 @@ available for dependency injection. Inject it using the interface:
{
try {
/** @var ResetPassword $intent */
$intent = $this->storage->fetch(Ulid::fromString($token));
$intent = $this->storage->fetch($token);
} catch (NotExistsException) {
// Link is invalid, expired, or it has been used already.
// ...
Expand Down
2 changes: 1 addition & 1 deletion docs/source/components/intent/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ using its identifier and proceed with the use case:
// noop.
}

public function __invoke(Ulid $identifier): Response
public function __invoke(Ulid|string $identifier): Response
{
try {
/** @var ResetPassword $intent */
Expand Down
3 changes: 3 additions & 0 deletions docs/source/components/intent/storages.rst
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ that your implementation honours the following rules:
* ``store()`` returns a newly generated ``Ulid``, which must be unguessable,
since it is the only thing which protects an intent from being fetched by
somebody else.
* ``fetch()`` and ``invalidate()`` accept an identifier which is a ``Ulid``, its
string representation, or any object which is ``Stringable``, so both have to
normalize such a value into a ``Ulid`` before it is used.
* ``fetch()`` throws ``NotExistsException`` if an intent does not exist, if it
has expired, or if it is not available yet.
* ``fetch()`` invalidates an intent after it has been fetched, unless it is
Expand Down
36 changes: 35 additions & 1 deletion docs/source/components/intent/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ An intent is fetched by using its identifier:
You will get the very same object which you have stored, so you may safely type
hint against your own classes.

Identifier may be an instance of ``Ulid``, its string representation, or any
object which is ``Stringable``. Since an identifier usually arrives from a
request, as a part of an URL, you may pass that value directly, without
converting it yourself:

.. code-block:: php
:linenos:

<?php

$intent = $storage->fetch($request->attributes->get('token'));

If an intent does not exist, if it has expired, if it is not available yet, or
if it has been fetched already, ``NotExistsException`` is thrown. From the
perspective of the code which fetches it, all these cases are the same and
Expand All @@ -115,6 +127,23 @@ should be handled in the same manner:
// Link is invalid, expired, or it has been used already.
}

Do note that a string which is not a valid ULID is not the same case. Such a
value never identified an intent, so ``\InvalidArgumentException`` is thrown
instead, before the storage is even queried. If you pass a value which comes
from a request, and you want to treat a malformed identifier in the same manner
as an intent which does not exist, catch it as well:

.. code-block:: php
:linenos:

<?php

try {
$intent = $storage->fetch($token);
} catch (NotExistsException|\InvalidArgumentException) {
// Link is malformed, invalid, expired, or it has been used already.
}

Preserving an intent
~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -149,8 +178,13 @@ process which has been initiated:

$storage->invalidate($identifier);

Identifier may be an instance of ``Ulid``, its string representation, or any
object which is ``Stringable``, exactly as it is the case when an intent is
fetched.

Method does not throw an exception if an intent with the given identifier does
not exist.
not exist. It does, however, throw ``\InvalidArgumentException`` if given a
string which is not a valid ULID.

Removing expired intents
------------------------
Expand Down
21 changes: 21 additions & 0 deletions src/RunOpenCode/Bundle/IntentBundle/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 RunOpenCode

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
16 changes: 15 additions & 1 deletion src/RunOpenCode/Bundle/IntentBundle/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
TODO
Intent Bundle
=============

_This is a **read-only** repository of mono-repository sub-split
from [https://github.com/RunOpenCode/phplib](https://github.com/RunOpenCode/phplib). Do not send PR or report issues
against this repository, use the one referenced with previously given URL._

Bundle integrates [Intent Component](https://github.com/RunOpenCode/intent) into Symfony applications.

## Resources

- [Report issues and suggest features](https://github.com/RunOpenCode/phplib/issues)
- [Send pull requests](https://github.com/RunOpenCode/phplib/pulls)
- [Changelog](https://github.com/RunOpenCode/phplib/blob/master/CHANGELOG)
- [License](https://github.com/RunOpenCode/phplib/blob/master/LICENSE)
21 changes: 21 additions & 0 deletions src/RunOpenCode/Component/Intent/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 RunOpenCode

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
34 changes: 33 additions & 1 deletion src/RunOpenCode/Component/Intent/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,33 @@
TODO
Intent Component
================

_This is a **read-only** repository of mono-repository sub-split
from [https://github.com/RunOpenCode/phplib](https://github.com/RunOpenCode/phplib). Do not send PR or report issues
against this repository, use the one referenced with previously given URL._

A library for temporary storing intents (messages/commands/states) which needs to be preserved between stateless
requests.

Password reset is a typical example: user requests a password reset, receives an email containing a link and, at some
point in time, clicks on that link in order to complete the process which has been started within some earlier request.
Session storage is not always an option here, since the request which completes the use case may originate from a
different browser, a different device, or even a different machine.

This library allows you to store any serializable object into a persistent storage and to retrieve it later by using a
randomly generated identifier which you may safely put into an URL:

- **Store any serializable object** and retrieve it later by using its identifier.
- **Time to live** is defined per intent, after which intent is no longer available and is removed from the storage.
- **Deferred availability** allows you to store an intent which becomes available at some moment in the future.
- **Invalidated on read** by default, so a single intent may be used only once, which is a sane default for one time
links.
- **Doctrine Dbal and PSR-6 storages** are provided out of the box, while other storages may be added with ease.

For usage within Symfony applications, see [Intent Bundle](https://github.com/RunOpenCode/intent-bundle).

## Resources

- [Report issues and suggest features](https://github.com/RunOpenCode/phplib/issues)
- [Send pull requests](https://github.com/RunOpenCode/phplib/pulls)
- [Changelog](https://github.com/RunOpenCode/phplib/blob/master/CHANGELOG)
- [License](https://github.com/RunOpenCode/phplib/blob/master/LICENSE)
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,21 @@ public function store(object $intent, int $ttl = 86400, ?\DateTimeInterface $fro
/**
* Fetch intent for given identifier.
*
* @param Ulid $identifier Identifier for which intent should be fetched.
* @param Ulid|\Stringable|string $identifier Identifier for which intent should be fetched.
* @param bool $invalidate Should intent be invalidated after fetch. Defaults to TRUE.
*
* @throws NotExistsException
*
*/
public function fetch(Ulid $identifier, bool $invalidate = true): object;
public function fetch(Ulid|\Stringable|string $identifier, bool $invalidate = true): object;

/**
* Invalidates intent with given identifier. Does not throw exception
* if intent with given identifier does not exists.
*
* @param Ulid|string $identifier Identifier for which intent should be invalidated.
*/
public function invalidate(Ulid $identifier): void;
public function invalidate(Ulid|\Stringable|string $identifier): void;

/**
* Remove all expired intents.
Expand Down
8 changes: 6 additions & 2 deletions src/RunOpenCode/Component/Intent/src/Storage/CacheStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ public function store(object $intent, int $ttl = 86400, ?\DateTimeInterface $fro
/**
* {@inheritdoc}
*/
public function fetch(Ulid $identifier, bool $invalidate = true): object
public function fetch(Ulid|\Stringable|string $identifier, bool $invalidate = true): object
{
$identifier = $identifier instanceof Ulid ? $identifier : Ulid::fromString((string)$identifier);

$item = $this->pool->getItem((string)$identifier);

if (!$item->isHit()) {
Expand Down Expand Up @@ -96,8 +98,10 @@ public function fetch(Ulid $identifier, bool $invalidate = true): object
/**
* {@inheritdoc}
*/
public function invalidate(Ulid $identifier): void
public function invalidate(Ulid|\Stringable|string $identifier): void
{
$identifier = $identifier instanceof Ulid ? $identifier : Ulid::fromString((string)$identifier);

$this->pool->deleteItem((string)$identifier);
}

Expand Down
9 changes: 6 additions & 3 deletions src/RunOpenCode/Component/Intent/src/Storage/DbalStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
namespace RunOpenCode\Component\Intent\Storage;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
Expand Down Expand Up @@ -67,8 +66,10 @@ public function store(object $intent, int $ttl = 86400, ?\DateTimeInterface $fro
/**
* {@inheritdoc}
*/
public function fetch(Ulid $identifier, bool $invalidate = true): object
public function fetch(Ulid|\Stringable|string $identifier, bool $invalidate = true): object
{
$identifier = $identifier instanceof Ulid ? $identifier : Ulid::fromString((string)$identifier);

$row = $this->connection->executeQuery(\sprintf(
'SELECT * FROM %s WHERE id = :id LIMIT 1',
$this->tableName,
Expand Down Expand Up @@ -123,8 +124,10 @@ public function fetch(Ulid $identifier, bool $invalidate = true): object
/**
* {@inheritdoc}
*/
public function invalidate(Ulid $identifier): void
public function invalidate(Ulid|\Stringable|string $identifier): void
{
$identifier = $identifier instanceof Ulid ? $identifier : Ulid::fromString((string)$identifier);

$this->connection->executeQuery(\sprintf('DELETE FROM %s WHERE id = :id', $this->tableName), [
'id' => $identifier,
], [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ public function invalidate_intent(): void
$storage->fetch($identifier);
}

#[Test]
public function fetch_with_invalid_identifier_throws_exception(): void
{
$this->expectException(\InvalidArgumentException::class);

$this->getIntentStorage()->fetch('foo');
}

#[Test]
public function invalidate_with_invalid_identifier_throws_exception(): void
{
$this->expectException(\InvalidArgumentException::class);

$this->getIntentStorage()->invalidate('foo');
}

#[Test]
public function maintenance(): void
{
Expand Down
Loading