Skip to content

Commit

Permalink
Add transactional behavior to custom closures
Browse files Browse the repository at this point in the history
  • Loading branch information
fntneves committed Apr 15, 2020
1 parent 19b8855 commit 4ce77d7
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 10 deletions.
53 changes: 44 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@
<a href="https://scrutinizer-ci.com/g/fntneves/laravel-transactional-events/?branch=master"><img src="https://scrutinizer-ci.com/g/fntneves/laravel-transactional-events/badges/quality-score.png?b=master" alt="Scrutinizer Code Quality"></a>
[![Total Downloads](https://poser.pugx.org/fntneves/laravel-transactional-events/downloads)](https://packagist.org/packages/fntneves/laravel-transactional-events)

This package introduces a transactional layer to Laravel Event Dispatcher.<br/>
Out of the box, it ensures consistency between events dispatched and database transactions.

This package adds a transaction-aware layer on top of the Laravel Event Dispatcher.<br/>
Basically, it holds events dispatched in a database transaction until the transaction successfully commits.<br/>
In case of transaction failure, the events are discarded and never dispatched.

* [Introduction](#introduction)
* [Installation](#installation)
* [Laravel](#laravel)
* [Lumen](#lumen)
* [Usage](#usage)
* [Configuration](#configuration)
* [F.A.Q.](#frequently-asked-questions)
* [Known Issues](#known-issues)

## Introduction

Let's start with a simple example of ordering tickets. Assume that it involves database changes and a payment registration and that the custom event `OrderWasProcessed` is dispatched immediately after the order is processed in the database.
Consider the following example of ordering tickets that involves database changes and payment operation.
The custom event `OrderWasProcessed` is dispatched immediately after the order is processed in the database.

```php
DB::transaction(function() {
Expand All @@ -29,9 +34,11 @@ DB::transaction(function() {
});
```

The transaction in the above example may fail for several reasons: an exception may occur in the `orderTickets` method or in the payment service or simply due to a deadlock.
The transaction in the above example may fail due to several reasons. For instance, due to an exception in the `orderTickets` method or cause by the Payment Service package or simply due to a deadlock.

A failure will rollback the database changes made during the transaction. However, this is not true for the `OrderWasProcessed` event, which is actually dispatched and eventually executed. Considering that this event may result in sending an e-mail with the order confirmation, managing it the right way becomes mandatory.
The failed transaction will undo the database changes performed during the transaction.
This is not true however for the `OrderWasProcessed` event, which was dispatched and eventually executed or enqueued.
Preventing the event to be dispatch may prevent embarrassing situations like confirmation emails sent after orders failure.

The purpose of this package is to actually dispatch events **if and only if** the transaction in which they were dispatched commits. For instance, in the above example the `OrderWasProcessed` event would not be dispatched if the transaction fails.

Expand Down Expand Up @@ -93,9 +100,10 @@ $app->register(Neves\Events\EventServiceProvider::class);

The transactional layer is enabled out of the box for the events placed under the `App\Events` namespace.

Additionally, this package offers two ways to mark events as transactional:
- Implement the `Neves\Events\Contracts\TransactionalEvent` contract (recommended)
- Change the [configuration file](#configuration) provided by this package
Additionally, this package offers three distinct ways to execute transactional-aware events or custom behavior:
- Implement the `Neves\Events\Contracts\TransactionalEvent` contract
- Use the `transactional` helper to pass a custom closure to be executed once transaction commits
- Change the [configuration file](#configuration) provided by this package (not recommended)

#### Use the contract, Luke:

Expand All @@ -117,7 +125,7 @@ class TicketsOrdered implements TransactionalEvent
...
}
```
As this package does not require any changes in your code, you are still able to use the `Event` facade and call the `event()` or `broadcast()` helper to dispatch an event:
As this package does not require any changes in your code, you are to use `Event` facade, call the `event()` or `broadcast()` helper to dispatch an event as follows:

```php
Event::dispatch(new App\Event\TicketsOrdered) // Using Event facade
Expand All @@ -129,6 +137,27 @@ Even if you are using queues, they will still work smothly because this package

**Reminder:** Events are considered transactional when they are dispatched within transactions. When an event is dispatched out of transactions, it bypasses the transactional layer.

#### What about Jobs?

In version **1.8.8**, this package introduced the `transactional` helper for applying the same behavior to custom instructions without the need to create a specific event.

This helper can be used to ensure that Jobs are dispatched only after the transaction successfully commits:

```php
DB::transaction(function () {
...

transactional(function () {
// Job will be dispatched only if the transaction commits.
ProcessOrderShippingJob::dispatch($order);
});

...
});
```

Under the hood, it creates a *TransactionalClosureEvent* event provided by this package.


## Configuration

Expand Down Expand Up @@ -162,6 +191,12 @@ Choose the events that should always bypass the transactional layer, i.e., shoul
],
```

## Frequently Asked Questions

#### Can I use it for Jobs?

Yes. From version **1.8.8**, as mentioned in [Usage](#usage) section, you can use the `transactional(Closure $callable)` helper to trigger jobs only after the transaction commits.

## Known issues

#### Transactional events are not dispatched in tests.
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
"autoload": {
"psr-0": {
"Neves\\": "src/"
}
},
"files": [
"src/helpers.php"
]
},
"extra": {
"laravel": {
Expand Down
3 changes: 3 additions & 0 deletions src/Neves/Events/EventServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public function register()
$eventDispatcher = $this->app->make(EventDispatcher::class);
$this->app->extend('events', function () use ($eventDispatcher) {
$dispatcher = new TransactionalDispatcher($eventDispatcher);
$dispatcher->listen(TransactionalClosureEvent::class, function(TransactionalClosureEvent $event) {
($event->getClosure())();
});
$dispatcher->setTransactionalEvents($this->app['config']->get('transactional-events.transactional'));
$dispatcher->setExcludedEvents($this->app['config']->get('transactional-events.excluded'));

Expand Down
22 changes: 22 additions & 0 deletions src/Neves/Events/TransactionalClosureEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php


namespace Neves\Events;


use Closure;
use Neves\Events\Contracts\TransactionalEvent;

class TransactionalClosureEvent implements TransactionalEvent
{
private $closure;

public function __construct(Closure $closure)
{
$this->closure = $closure;
}

public function getClosure(): Closure {
return $this->closure;
}
}
19 changes: 19 additions & 0 deletions src/helpers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Neves\Events\TransactionalClosureEvent;

if (! function_exists('transactional')) {
/**
* Add an element to an array using "dot" notation if it doesn't exist.
*
* @param \Closure $callable
* @return void
*/
function transactional(Closure $callable)
{
$dispatcher = app(DispatcherContract::class);

$dispatcher->dispatch(new TransactionalClosureEvent($callable));
}
}
29 changes: 29 additions & 0 deletions tests/TransactionalDispatcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Neves\Events\Contracts\TransactionalEvent;
use Neves\Events\EventServiceProvider;
use Neves\Events\TransactionalClosureEvent;
use Neves\Events\TransactionalDispatcher;
use Orchestra\Testbench\TestCase;

Expand Down Expand Up @@ -268,6 +269,34 @@ public function it_immediately_dispatches_specific_events_excluded_on_a_pattern(
});
}

/** @test */
public function it_provides_transactional_behavior_of_custom_closures()
{
DB::transaction(function () {
$this->dispatcher->dispatch(new TransactionalClosureEvent(function() {
$_SERVER['__events'] = 'bar';
}));

$this->assertArrayNotHasKey('__events', $_SERVER);
});

$this->assertEquals('bar', $_SERVER['__events']);
}

/** @test */
public function it_provides_transactional_behavior_of_custom_closures_using_transactional_helper()
{
DB::transaction(function () {
transactional(function() {
$_SERVER['__events'] = 'bar';
});

$this->assertArrayNotHasKey('__events', $_SERVER);
});

$this->assertEquals('bar', $_SERVER['__events']);
}

/**
* Regression test: Fix infinite loop caused by TransactionCommitted (#12).
* @test
Expand Down

0 comments on commit 4ce77d7

Please sign in to comment.