Based on the response of @cirkopel, I've created a simple class that creates a single transaction, then only commits if the method that is invoking the commit
method is the first caller to the transaction.
Each time that we call DB::beginTransaction()
the transaction level is incremented, that's the point of only keeping 1 transaction as had mentioned by @cirkopel.
Taking care of this, another problem that we need to approach is when some error happens and we need to call DB::rollback()
it must be called before any DB::commit()
.
To summarize, we need to only start the transaction in the root invoker and commit the transaction in this same, considering that we could have many services related between them with their own transaction management.
The class:
<?php
namespace App\Models;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DBUtils
{
/**
* This method starts a transaction only if the level of the transaction is 0.
* So it is useful when you have nested transactions.
* @return int The number of transaction-level that invokes the method.
*/
public static function beginSingleTransaction(): int {
$current_level = static::getCurrentTransactionLevel();
if($current_level ==0 ){
Log::debug('-------------- Beginning Transaction at LEVEL = ' . $current_level .' -------------- ');
DB::beginTransaction();
}
return $current_level;
}
/**
* This method commits the current transaction up to reach the root invoker.
* So it is useful when you have nested transactions.
*
* @param int $at_level indicate the method that invokes the current transaction, pass the value of {#beginSingleTransaction}
*
* @return void
*/
public static function commitSingleTransaction(int $at_level) {
if($at_level == 0) {
Log::debug('-------------- Commit Transaction at LEVEL = ' . $at_level. ' -------------- ');
DB::commit();
}
}
private static function getCurrentTransactionLevel(): int {
return DB::connection(DB::getDefaultConnection())->transactionLevel();
}
}
Now we can have multiples services that could be combined.
DeleteUseCase.php
<?php
namespace App\Features\UseCases;
use App\Models\DBUtils;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* DeleteUseCase.
* ...
*/
class DeleteUseCase
{
/**
* @throws \Exception
*/
public function delete(int $id): bool {
$level = DBUtils::beginSingleTransaction();
try {
// your process of deletion (repository, eloquent, etc)
DBUtils::commitSingleTransaction($level);
return true;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
}
We can call DeleteUseCase
inside another class creating nested transactions:
OtherUseCase.php
<?php
namespace App\Features\UseCases;
use App\Features\UseCases\DeleteUseCase.php;
use App\Models\DBUtils;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* OtherUseCase.
* ...
*/
class OtherUseCase
{
private DeleteUseCase $deleteUseCase;
public function __construct(
DeleteUseCase $deleteUseCase)
{
$this->deleteUseCase = $deleteUseCase;
}
/**
* @throws \Exception
*/
public function other(int $id): bool {
// the actual level is 0, after this the level will be 1
$level = DBUtils::beginSingleTransaction();
try {
// your OTHER process
// .....
// call to the inner service, here no transaction will be created
// and also any commit will be performed, because the delete method
// is not the root invoker.
$this->deleteUseCase->delete($id);
// here the transaction will be performed.
DBUtils::commitSingleTransaction($level);
return true;
} catch (\Exception $e) {
// at any point, as only one transaction was created the rollback is performed without problem.
DB::rollBack();
throw $e;
}
}
}
You can invoke the services together or separately and a single transaction would be created.
I hope that it could be useful for someone.
Regards.