Skip to content

Managing Database Transactions in Winter Boot Services

Winter Boot offers two ways to manage transactions. Declarative transactions use the #[Transactional] attribute: the framework wraps the method in a transaction and commits or rolls back for you. Programmatic transactions use PlatformTransactionManager directly, which is useful when a transaction does not map to a single method call, or when the datasource is chosen at runtime (multi-tenant apps).

To use #[Transactional], add #[EnableTransactionManagement] to your application entry-point class. (Programmatic transactions work without it — a transaction manager bean is registered for every datasource either way.)

MyApplication.php
use dev\winterframework\stereotype\WinterBootApplication;
use dev\winterframework\core\app\WinterWebSwooleApplication;
use dev\winterframework\stereotype\txn\EnableTransactionManagement;
#[EnableTransactionManagement]
#[WinterBootApplication(
configDirectory: [__DIR__ . '/config'],
scanNamespaces: [
['com\\example\\myapp', __DIR__ . '/src']
]
)]
class MyApplication {
public static function main(): void {
(new WinterWebSwooleApplication())->run(MyApplication::class);
}
}

Declarative Transactions with #[Transactional]

Section titled “Declarative Transactions with #[Transactional]”

Annotate any service or component method with #[Transactional] to have the framework begin a transaction before the method runs, commit it on success, or roll it back on any uncaught exception.

PaymentService.php
use dev\winterframework\stereotype\Service;
use dev\winterframework\txn\stereotype\Transactional;
#[Service]
class PaymentService {
#[Transactional]
public function processPayment(int $orderId, float $amount): void {
// All database operations here run inside a single transaction.
// Any uncaught exception triggers an automatic rollback.
}
}
  • transactionManager string (default: default) Bean name of the PlatformTransactionManager to use. Defaults to the primary datasource’s transaction manager.

  • propagation int (default: PROPAGATION_REQUIRED) Transaction propagation behavior. See the propagation constants table below.

  • isolation int (default: ISOLATION_DEFAULT) Database isolation level to request from the driver.

  • timeout int (default: TIMEOUT_DEFAULT) Transaction timeout in seconds. -1 means use the driver default.

  • readOnly bool (default: false) Mark the transaction as read-only. It is rolled back at the end instead of committed.

  • rollbackFor array (default: all exceptions) Array of exception class names that must trigger a rollback.

  • noRollbackFor array (default: none) Array of exception class names that must not trigger a rollback. When one of them is thrown, the transaction is committed and the exception is re-thrown.

  • label array (default: []) Arbitrary string labels to associate with the transaction for informational purposes.

Constant Value Behavior
PROPAGATION_REQUIRED 0 Use the current transaction, or create a new one if none exists (default)
PROPAGATION_SUPPORTS 1 Use the current transaction if one exists; otherwise run non-transactionally
PROPAGATION_MANDATORY 2 Use the current transaction; throw an exception if none exists
PROPAGATION_REQUIRES_NEW 3 Always create a new transaction, suspending any existing one. Runs on a separate connection, so it commits or rolls back independently of the outer transaction
PROPAGATION_NOT_SUPPORTED 4 Always run non-transactionally, suspending any existing transaction. Runs on a separate auto-commit connection
PROPAGATION_NEVER 5 Run non-transactionally; throw an exception if a transaction exists
PROPAGATION_NESTED 6 Run within a savepoint of the current transaction — not supported by the PDO/OCI managers (throws)

PlatformTransactionManager gives you direct control over the transaction lifecycle. Inject it with #[Autowired] for the primary datasource, or by bean name for a named datasource, then call getTransaction(), commit(), and rollback() yourself.

  • getTransaction(TransactionDefinition $definition) TransactionStatus Begin or join a transaction according to the given definition.

  • commit(TransactionStatus $status) void Commit the transaction represented by $status.

  • rollback(TransactionStatus $status) void Roll back the transaction represented by $status.

DefaultTransactionDefinition is the standard implementation of TransactionDefinition. Configure it before calling getTransaction():

use dev\winterframework\txn\support\DefaultTransactionDefinition;
use dev\winterframework\txn\Transaction;
$def = new DefaultTransactionDefinition();
$def->setPropagationBehavior(Transaction::PROPAGATION_REQUIRES_NEW);
$def->setIsolationLevel(Transaction::ISOLATION_READ_COMMITTED);
$def->setReadOnly(false);
$def->setTimeout(30);

The following service shows the full try/commit/catch/rollback pattern using an injected PlatformTransactionManager:

UserService.php
use dev\winterframework\pdbc\ex\EmptyResultDataAccessException;
use dev\winterframework\pdbc\PdbcTemplate;
use dev\winterframework\stereotype\Autowired;
use dev\winterframework\stereotype\Service;
use dev\winterframework\txn\PlatformTransactionManager;
use dev\winterframework\txn\support\DefaultTransactionDefinition;
use dev\winterframework\util\log\Wlf4p;
#[Service]
class UserService {
use Wlf4p;
#[Autowired]
private PdbcTemplate $pdbc;
#[Autowired]
private PlatformTransactionManager $txnMgr;
public function createUser(User $user): User {
$status = $this->txnMgr->getTransaction(new DefaultTransactionDefinition());
try {
$sql = "INSERT INTO users (name, email, age) VALUES (:name, :email, :age) RETURNING id";
$ret = [];
$result = $this->pdbc->update($sql, [
'name' => $user->getName(),
'email' => $user->getEmail(),
'age' => $user->getAge(),
], [], $ret);
if ($result) {
$user->setId(intval($ret['id']));
}
$this->txnMgr->commit($status);
return $user;
} catch (\Throwable $e) {
$this->txnMgr->rollback($status);
throw $e;
}
}
public function findById(int $id): ?User {
try {
return $this->pdbc->queryForObject(
"SELECT * FROM users WHERE id = :id",
['id' => $id],
User::class
);
} catch (EmptyResultDataAccessException) {
return null;
}
}
}

  • Transaction state belongs to the current request. Concurrent requests in the same worker never join, commit, or roll back each other’s transactions.
  • REQUIRES_NEW and NOT_SUPPORTED borrow a second connection from the pool for the duration of the call. The data source must implement IsolatedConnectionProvider to get that second connection (the built-in PDO and OCI data sources do). With a custom data source that does not implement it, the inner work runs on the outer connection (so it is not isolated) and a warning is logged once. The second connection counts against the pool’s maxConnections, but it never waits for a free slot: the request already holds one, and waiting could deadlock concurrent requests on each other. Under full load the pool can therefore briefly hold up to maxConnections extra connections, one per nested REQUIRES_NEW/NOT_SUPPORTED in flight; new requests wait until it is back under the cap.
  • When a request ends, its connection goes back to the pool only after any open transaction is rolled back, so the next request always starts clean.
  • If a connection cannot be opened inside a request, CannotGetConnectionException is thrown; the request never falls back to a shared connection.

PDO does not natively support nested (savepoint-based) transactions. PROPAGATION_NESTED is defined in the Transaction interface, but the PDO/OCI transaction managers reject it (NestedTransactionNotSupportedException / SQLFeatureNotSupportedException).


Declarative (#[Transactional])

Best for service-layer methods with straightforward, single-method transaction boundaries. Zero boilerplate — commit and rollback happen automatically based on whether the method throws.

Programmatic (PlatformTransactionManager)

Best when transaction scope spans multiple methods, when you need conditional commits, or when working with multi-tenant dynamic data sources where the transaction manager is resolved at runtime.