Дізнайтеся, як використовувати мультидокументні ACID-транзакції MongoDB у Laravel, щоб зберегти узгодженість даних між колекціями, коли одних атомарних операцій недостатньо. Практичні приклади: ролбек і обробка збоїв.
DB::transaction() з MongoDBУ попередній статті ми вирішили race conditions за допомогою атомарних операторів MongoDB, наприклад $inc. Дебети гаманця та оновлення запасів більше не піддавалися умовам гонки. Перемога, правда?
Не зовсім. Тестуючи сценарії збоїв, я виявив нову проблему: що станеться, якщо додаток впаде після списання з гаманця, але перед створенням замовлення? Клієнт втрачає $80, а покупка не фіксується.
Атомарні операції гарантують консистентність одного документа, але наш чек-аут зачіпає три колекції: wallets, products і orders. Потрібно, щоб усі три оновлення відбулися разом або жодне — тут виручають мультидокументні транзакції MongoDB.
Laravel робить їх використання надзвичайно простим.
Покажу, що може піти не так. Ось наш чек-аут з попередньої статті:
public function checkout(Request $request)
{
// ... validation ...
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
// Step 1: Debit wallet atomically
$walletResult = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
['user_id' => $userId, 'balance' => ['$gte' => $amount]],
['$inc' => ['balance' => -$amount]]
);
});
if ($walletResult->getModifiedCount() === 0) {
return response()->json(['error' => 'Insufficient funds'], 400);
}
// 💥 WHAT IF THE APP CRASHES HERE? 💥
// Step 2: Decrease inventory atomically
$inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
['_id' => new ObjectId($productId), 'stock' => ['$gte' => $quantity]],
['$inc' => ['stock' => -$quantity]]
);
});
// Step 3: Create order
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount
]);
return response()->json(['order' => $order]);
}
Примітка щодо пошуку по ID: метод
Product::findOrFail($productId)(Eloquent) автоматично конвертує рядок у ObjectId. У raw-запиті треба явно створитиnew ObjectId($productId), бо ми працюємо безпосередньо з драйвером MongoDB. Пакет Laravel MongoDB забезпечує цю конверсію у вищому рівні API.
Зімітуймо падіння додатка у тесті:
public function test_partial_failure_leaves_inconsistent_state()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
// Modify controller to crash after wallet debit
try {
// Debit wallet
Wallet::raw(function ($collection) {
return $collection->updateOne(
['user_id' => 'user-1', 'balance' => ['$gte' => 1000]],
['$inc' => ['balance' => -1000]]
);
});
// Simulate crash
throw new \Exception('Simulated server crash');
// These never execute
Product::raw(function ($collection) { ... });
Order::create([...]);
} catch (\Exception $e) {
// App crashed
}
// Check database state
$wallet->refresh();
$product->refresh();
$order = Order::where('user_id', 'user-1')->first();
dump('=== AFTER CRASH ===');
dump('Wallet balance: ' . $wallet->balance); // $500 (debited!)
dump('Product stock: ' . $product->stock); // 5 (unchanged!)
dump('Order exists: ' . ($order ? 'Yes' : 'No')); // No!
$this->assertEquals(1500, $wallet->balance,
'Wallet should not be debited if order was not created');
}
Результат:
=== AFTER CRASH ===
Wallet balance: 500
Product stock: 5
Order exists: No
Клієнт втратив $1,000, але замовлення немає. Інвентар не змінився — дані стали неконсистентними.
Кожна операція була атомарною (жодних race conditions), але разом вони не гарантують цілісність — потрібні транзакції.
Інваріант — це умова, яка завжди має бути істинною для ваших даних. У нашій e‑commerce системі кілька критичних інваріантів:
Інваріант 1: збереження грошей
Sum of all wallet debits = Sum of all order amounts
Якщо ми списали $1,000 з гаманців, але створили замовлень лише на $800 — гроші «зникли».
Інваріант 2: точність запасів
Physical stock = Database stock - Sum of order quantities
Створювати замовлення без зменшення запасів — означає перепродати товар. Зменшувати запаси без замовлень — означає втратити їхній облік.
Інваріант 3: узгодженість гаманець‑замовлення
If wallet is debited, corresponding order must exist
If order exists, wallet must be debited
Кожне списання має мати чек, і кожен чек — списання.
Коли операції охоплюють кілька документів чи колекцій, атомарних операцій замало — потрібні транзакції.
Мультидокументні транзакції MongoDB працюють як у традиційних реляційних базах (MySQL, PostgreSQL). Вони дають ACID‑гарантії:
Як зазначено в документації MongoDB:
"Multi-document transactions make MongoDB the only database to combine the ACID guarantees of traditional relational databases and the speed, flexibility, and power of the document model, with an intelligent distributed systems design to scale-out and place data where you need it. Through snapshot isolation, transactions provide a consistent view of data and enforce all-or-nothing execution to maintain data integrity".
За допомогою snapshot isolation транзакції дають консистентний вигляд даних і забезпечують виконання «все або нічого».
Метод Laravel DB::transaction() працює з MongoDB через пакет Laravel MongoDB. Ось базовий синтаксис:
use Illuminate\Support\Facades\DB;
DB::connection('mongodb')->transaction(function () {
// All operations here are part of the transaction
// If any operation fails, everything rolls back
}, 5); // Retry up to 5 times on deadlock
Важливо: вбудований механізм повторних спроб Laravel обробляє лише deadlock-винятки. Як ви побачите у розділі "When Transactions Fail (And That's OK)", транзакції MongoDB можуть падати з інших причин — мережеві перебої, вибори primary у репліка‑сеті, тимчасові помилки транзакцій. Для повної обробки MongoDB‑специфічних помилок потрібна ручна логіка повторів із експоненційним бекофом, яку ми розглянемо далі.
Перевага Laravel API в тому, що синтаксис однаковий для MongoDB, MySQL чи PostgreSQL — знайомий і простий.
Рефакторимо чек-аут, щоб використовувати транзакцію:
<?php
namespace App\Http\Controllers;
use App\Models\Wallet;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use MongoDB\BSON\ObjectId;
class CheckoutController extends Controller
{
public function checkout(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|string',
'product_id' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
$userId = $validated['user_id'];
$productId = $validated['product_id'];
$quantity = $validated['quantity'];
try {
$order = DB::connection('mongodb')->transaction(function () use ($userId, $productId, $quantity) {
// Get product price
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
// Step 1: Debit wallet atomically
$walletResult = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
[
'user_id' => $userId,
'balance' => ['$gte' => $amount]
],
[
'$inc' => ['balance' => -$amount]
]
);
});
if ($walletResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient funds');
}
// Step 2: Decrease inventory atomically
$inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
[
'_id' => new ObjectId($productId),
'stock' => ['$gte' => $quantity]
],
[
'$inc' => ['stock' => -$quantity]
]
);
});
if ($inventoryResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient stock');
}
// Step 3: Create order
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount,
'status' => 'completed',
'created_at' => now()
]);
return $order;
});
return response()->json([
'success' => true,
'order' => $order
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}
Ключові зміни:
DB::connection('mongodb')->transaction()order з closure, щоб мати доступ поза неюТепер усі три операції атомарні в сукупності. Якщо будь‑який крок не вдасться — MongoDB автоматично зробить ролбек усіх змін.
Перевіримо, що транзакції справді працюють. Створіть набір тестів:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Wallet;
use App\Models\Product;
use App\Models\Order;
class TransactionTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Wallet::truncate();
Product::truncate();
Order::truncate();
}
public function test_successful_checkout_commits_all_changes()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
$response = $this->postJson('/api/checkout', [
'user_id' => 'user-1',
'product_id' => $product->id,
'quantity' => 1
]);
$response->assertStatus(200);
// All changes should be committed
$wallet->refresh();
$product->refresh();
$this->assertEquals(500.00, $wallet->balance, 'Wallet debited');
$this->assertEquals(4, $product->stock, 'Inventory decreased');
$this->assertEquals(1, Order::count(), 'Order created');
$order = Order::first();
$this->assertEquals('user-1', $order->user_id);
$this->assertEquals(1000.00, $order->amount);
}
public function test_insufficient_funds_rolls_back_entire_transaction()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 500.00 // Not enough!
]);
$response = $this->postJson('/api/checkout', [
'user_id' => 'user-1',
'product_id' => $product->id,
'quantity' => 1
]);
$response->assertStatus(400);
$response->assertJson(['error' => 'Insufficient funds']);
// Nothing should change
$wallet->refresh();
$product->refresh();
$this->assertEquals(500.00, $wallet->balance, 'Wallet unchanged');
$this->assertEquals(5, $product->stock, 'Inventory unchanged');
$this->assertEquals(0, Order::count(), 'No order created');
}
public function test_out_of_stock_rolls_back_wallet_debit()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 0 // Out of stock!
]);
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
$response = $this->postJson('/api/checkout', [
'user_id' => 'user-1',
'product_id' => $product->id,
'quantity' => 1
]);
$response->assertStatus(400);
$response->assertJson(['error' => 'Insufficient stock']);
// Wallet should NOT be debited
$wallet->refresh();
$this->assertEquals(1500.00, $wallet->balance,
'Wallet should not be debited when out of stock');
$this->assertEquals(0, Order::count(), 'No order created');
}
public function test_simulated_crash_rolls_back_transaction()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
// Simulate a crash by throwing an exception mid-transaction
try {
DB::connection('mongodb')->transaction(function () use ($wallet, $product) {
// Debit wallet
Wallet::raw(function ($collection) {
return $collection->updateOne(
['user_id' => 'user-1', 'balance' => ['$gte' => 1000]],
['$inc' => ['balance' => -1000]]
);
});
// Simulate crash
throw new \Exception('Simulated crash');
});
} catch (\Exception $e) {
// Transaction aborted
}
// Check that everything was rolled back
$wallet->refresh();
$product->refresh();
$this->assertEquals(1500.00, $wallet->balance,
'Transaction should rollback on crash');
$this->assertEquals(5, $product->stock, 'Stock unchanged');
$this->assertEquals(0, Order::count(), 'No order created');
}
}
Запустіть тести:
php artisan test --filter=TransactionTest
Всі тести мають пройти, підтвердивши, що:
Транзакції не усувають помилки — вони змінюють їхню поведінку. За документацією MongoDB, транзакції можуть не пройти через:
Коли транзакція падає, MongoDB гарантує:
Отже, обробляємо ці сценарії так:
public function checkout(Request $request)
{
// ... validation ...
$maxAttempts = 3;
$attempt = 0;
while ($attempt < $maxAttempts) {
$attempt++;
try {
$order = DB::connection('mongodb')->transaction(function () use ($userId, $productId, $quantity) {
// ... transaction logic ...
});
return response()->json([
'success' => true,
'order' => $order
]);
} catch (\MongoDB\Driver\Exception\RuntimeException $e) {
// Transient transaction error - might succeed on retry
if (str_contains($e->getMessage(), 'TransientTransactionError')) {
if ($attempt < $maxAttempts) {
\Log::warning('Transient transaction error, retrying', [
'attempt' => $attempt,
'error' => $e->getMessage()
]);
// Wait before retry (exponential backoff)
usleep(100000 * $attempt); // 100ms, 200ms, 300ms
continue;
}
}
// Non-transient error or max attempts reached
\Log::error('Transaction failed', [
'error' => $e->getMessage(),
'attempts' => $attempt
]);
return response()->json([
'error' => 'Transaction failed, please try again'
], 500);
} catch (\Exception $e) {
// Business logic error (insufficient funds, out of stock)
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}
Ця реалізація:
Драйвер MongoDB має свої вбудовані retry‑механізми, але розуміння типів помилок робить додаток більш стійким.
Laravel надає простіший спосіб для повторних спроб — можна вказати кількість ретраїв прямо:
DB::connection('mongodb')->transaction(function () {
// Transaction logic
}, 5); // Retry up to 5 times on deadlock
Але це працює лише для deadlock’ів. Для повної обробки транзієнтних помилок використовуйте ручний цикл із бекофом, як вище.
Після введення транзакцій я провів бенчмарки і помітив, що чек-аут в середньому став 450ms — значно повільніше. Помилка була в тому, що всього забагато виконувалося всередині транзакції.
Ось помилка:
// ❌ BAD: Transaction doing too much
DB::connection('mongodb')->transaction(function () use ($order, $user) {
// Database operations
Wallet::raw(...);
Product::raw(...);
Order::create(...);
// External services - DON'T DO THIS IN A TRANSACTION!
Mail::to($user->email)->send(new OrderConfirmation($order));
Http::post('https://shipping-api.com/create-label', [
'order_id' => $order->id
]);
// Analytics
Analytics::track('purchase', $order->toArray());
});
Транзакція тримає блокування бази, поки чекає відповіді пошти, API доставки або аналітики — це погано для продуктивності.
Ось виправлення:
// ✅ GOOD: Transaction only for database operations
public function checkout(Request $request)
{
// ... validation ...
// Transaction: ONLY atomic database changes
$order = DB::connection('mongodb')->transaction(function () use ($userId, $productId, $quantity) {
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
// Atomic wallet debit
$walletResult = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
['user_id' => $userId, 'balance' => ['$gte' => $amount]],
['$inc' => ['balance' => -$amount]]
);
});
if ($walletResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient funds');
}
// Atomic inventory decrease
$inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
['_id' => new ObjectId($productId), 'stock' => ['$gte' => $quantity]],
['$inc' => ['stock' => -$quantity]]
);
});
if ($inventoryResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient stock');
}
// Create order
return Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount,
'status' => 'completed'
]);
});
// AFTER transaction commits, do the slow stuff
// These can fail without affecting data consistency
try {
$user = User::find($userId);
Mail::to($user->email)->send(new OrderConfirmation($order));
} catch (\Exception $e) {
\Log::error('Email failed', [
'order_id' => $order->id,
'error' => $e->getMessage()
]);
// Queue for retry, but order is already committed
}
try {
Http::post('https://shipping-api.com/create-label', [
'order_id' => $order->id,
'address' => $user->address
]);
} catch (\Exception $e) {
\Log::error('Shipping label failed', [
'order_id' => $order->id,
'error' => $e->getMessage()
]);
// Queue for retry
}
// Analytics can fail silently
try {
Analytics::track('purchase', $order->toArray());
} catch (\Exception $e) {
\Log::warning('Analytics tracking failed', ['error' => $e->getMessage()]);
}
return response()->json([
'success' => true,
'order' => $order
]);
}
Після рефакторингу чек-аут впав до ~150ms — покращення на 67%.
Золоте правило: тримайте транзакції лише для критичних операцій з базою. Все повільне — після commit.
Щоби покращити швидкодію транзакцій, створюйте індекси на полях, які часто запитуються. Приклад команди для створення індексів:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CreateMongoIndexes extends Command
{
protected $signature = 'mongo:create-indexes';
protected $description = 'Create MongoDB indexes for transaction optimization';
public function handle()
{
$db = DB::connection('mongodb')->getMongoDB();
// Index on wallet user_id for fast lookups during checkout
$db->wallets->createIndex(['user_id' => 1], ['unique' => true]);
$this->info('✓ Created index on wallets.user_id');
// Compound index on wallet for conditional updates
$db->wallets->createIndex(['user_id' => 1, 'balance' => 1]);
$this->info('✓ Created compound index on wallets');
// Index on product stock for inventory checks
$db->products->createIndex(['stock' => 1]);
$this->info('✓ Created index on products.stock');
// Compound index on orders for user queries
$db->orders->createIndex(['user_id' => 1, 'created_at' => -1]);
$this->info('✓ Created compound index on orders');
// Index on order status for filtering
$db->orders->createIndex(['status' => 1]);
$this->info('✓ Created index on orders.status');
$this->info("\n✅ All indexes created successfully!");
$this->info('Transaction performance optimized.');
}
}
Зареєструйте команду та виконайте:
php artisan mongo:create-indexes
Індекси на полях, що використовуються в запитах транзакцій, значно підвищують продуктивність і знижують конкуренцію за блокування.
Транзакції вирішують конкретні проблеми, але не завжди потрібні. Коли їх уникати:
1. Операції над одним документом (використовуйте атомарні оператори):
// ❌ Don't use transaction
DB::connection('mongodb')->transaction(function () {
Product::raw(fn($c) => $c->updateOne(
['_id' => $productId],
['$inc' => ['views' => 1]]
));
});
// ✅ Just use atomic operation
Product::raw(fn($c) => $c->updateOne(
['_id' => $productId],
['$inc' => ['views' => 1]]
));
2. Коли прийнятна eventual consistency:
// ❌ Don't use transaction for analytics
DB::connection('mongodb')->transaction(function () {
UserActivity::create([
'user_id' => $userId,
'action' => 'viewed_product',
'product_id' => $productId
]);
});
// ✅ Just create directly
UserActivity::create([
'user_id' => $userId,
'action' => 'viewed_product',
'product_id' => $productId
]);
3. Високонавантажені операції, де критична швидкість:
// ❌ Don't use transaction for logs
DB::connection('mongodb')->transaction(function () {
Log::create(['message' => 'User logged in']);
});
// ✅ Just insert
Log::create(['message' => 'User logged in']);
Коли використовувати транзакції:
Ще один приклад — система бронювань готелів:
<?php
namespace App\Http\Controllers;
use App\Models\Rental;
use App\Models\Booking;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use MongoDB\BSON\ObjectId;
class BookingController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'rental_id' => 'required|string',
'user_id' => 'required|string',
'check_in' => 'required|date',
'check_out' => 'required|date|after:check_in',
]);
try {
$booking = DB::connection('mongodb')->transaction(function () use ($validated) {
// Update rental availability
$rentalResult = Rental::raw(function ($collection) use ($validated) {
return $collection->updateOne(
[
'_id' => new ObjectId($validated['rental_id']),
'available' => true
],
[
'$set' => ['available' => false]
]
);
});
if ($rentalResult->getModifiedCount() === 0) {
throw new \Exception('Rental not available');
}
// Create booking record
$booking = Booking::create([
'rental_id' => $validated['rental_id'],
'user_id' => $validated['user_id'],
'check_in' => $validated['check_in'],
'check_out' => $validated['check_out'],
'status' => 'confirmed'
]);
return $booking;
});
return response()->json([
'success' => true,
'booking' => $booking
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}
Якщо обидві дії вдалися — транзакція комітиться. Якщо ні — ролбек гарантує узгодженість доступності та запису броні.
Зробіть бенчмарк, щоб виміряти накладні витрати транзакцій:
public function test_transaction_performance()
{
// Setup test data
for ($i = 0; $i < 100; $i++) {
Product::create([
'name' => "Product {$i}",
'price' => 50.00,
'stock' => 100
]);
Wallet::create([
'user_id' => "user-{$i}",
'balance' => 1000.00
]);
}
$start = microtime(true);
// Run 100 transactional checkouts
for ($i = 0; $i < 100; $i++) {
$product = Product::skip($i)->first();
$this->postJson('/api/checkout', [
'user_id' => "user-{$i}",
'product_id' => $product->id,
'quantity' => 1
]);
}
$duration = (microtime(true) - $start) * 1000;
$average = $duration / 100;
dump("100 transactional checkouts in {$duration}ms");
dump("Average: {$average}ms per checkout");
// With optimized transactions (no external calls), should be under 200ms average
$this->assertLessThan(200, $average,
'Transactional checkout should average under 200ms');
}
Ціль: менше 200ms на чек-аут при правильно обмежених транзакціях.
DB::transaction() працює однаково з MongoDB і SQL — знайомий синтаксисАтомарні оператори (наприклад, $inc) працюють над одним документом в одному неділимому кроці — швидко і просто. Транзакції координують кілька операцій над багатьма документами чи колекціями, гарантуючи, що всі вони успішні або всі відмінені. Використовуйте атомарні операції для оновлення одного документа (лічильники тощо), транзакції — коли потрібна узгодженість між документами (списання з гаманця + створення замовлення).
За замовчуванням MongoDB має таймаут транзакції 60 секунд. Насправді транзакції повинні бути значно коротшими — бажано <100ms. Довгі транзакції тримають блокування, знижують пропускну здатність. Кладіть у транзакцію тільки критичні операції з БД, а все повільне — поза нею.
MongoDB гарантує повний ролбек. Якщо транзакція таймаутнула чи сталася падіння, MongoDB автоматично abort і відміняє всі операції в транзакції. База повертається у стан до транзакції. Додаток отримує виключення — його можна ловити й обробляти (повтор, повідомлення користувачу тощо).
Ні, MongoDB не підтримує nested transactions. Якщо викликати DB::transaction() усередині іншої транзакції, Laravel трактує їх як одну — всі операції належатимуть зовнішній транзакції. Це стандартна поведінка: транзакції плоскі. Проєктуйте код з однією областю транзакції для пов'язаних операцій.
Розрізняйте транзієнтні і бізнес‑помилки. Транзієнтні (мережа, вибори кластера) — повторюйте з експоненційним бекофом. Бізнес‑помилки (недостатні кошти, немає на складі) — відразу поверніть зрозуміле повідомлення користувачу. Логуйте невдачі з контекстом (user id, деталі операції). Стежте за частотою ретраїв: її підвищення сигналізує про інфраструктурні проблеми.
Ми реалізували надійну транзакційну систему чек‑ауту, що зберігає консистентність між колекціями. Але залишається критична проблема: що, якщо транзакція комітиться, а відповідь ніколи не доходить до клієнта? Фронтенд повторює запит, і клієнта можуть списати двічі.
У наступній статті — "Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions" — ми реалізуємо idempotency keys, щоб зробити операції чек‑ауту безпечними для повторів навіть при мережевих збоях.
Досліджуйте потужний пакет Intervention Image для PHP, який виводить редагування зображень на новий рівень з оновленою версією 3. Чи готові ви дізнатися, які нові можливості та функції чекають на вас у цьому інструменті
Модельний контекстний протокол (MCP) відкриває нові горизонти в інтеграції AI-додатків з PHP. Дізнайтеся, як легко створити сервер, що відповідає MCP, та які можливості відкриваються для вашого проєкту
Laravel пропонує зручні методи для роботи з датами, які значно спрощують запити до бази даних. Досліджуйте, як ці інтуїтивно зрозумілі функції допомагають створювати чіткі та зрозумілі умови для роботи з часовими даними!