База даних
Lift постачає невеликий, але справжній шар бази даних поверх PDO: плавний конструктор запитів, схему/міграції, опційну модель active-record, м’яке видалення, пагінацію та підтримку кількох з’єднань. MySQL, PostgreSQL і SQLite підтримуються «з коробки».
Ментальна модель: усе починається з
Connection(одне на базу даних).$db->table('users')дає вам плавнийQueryBuilder.Schemaвиконує DDL через те саме з’єднання.Model— це тонка об’єктна обгортка навколо конструктора — ви можете повністю її ігнорувати, якщо віддаєте перевагу стилю конструктора запитів.
1. Підключення
Найчистіший спосіб: побудувати Connection один раз і покласти його в контейнер.
use Lift\Database\Connection;
$app->singleton(Connection::class, fn() => Connection::fromConfig([
'driver' => 'mysql', // mysql | mariadb | pgsql | sqlite
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'myapp',
'username' => 'app',
'password' => $_ENV['DB_PASS'],
'charset' => 'utf8mb4',
]));
SQLite (чудово для прототипів і тестів):
Connection::fromConfig([
'driver' => 'sqlite',
'database' => __DIR__ . '/../database.sqlite', // або ':memory:'
]);
Потім впроваджуйте будь-де:
class UserRepository
{
public function __construct(private readonly Connection $db) {}
public function all(): array
{
return $this->db->table('users')->orderBy('id')->get();
}
}
Або побудуйте одне напряму, коли DI поки не потрібен:
$db = new Connection('sqlite::memory:');
Режим помилок PDO встановлено в ERRMODE_EXCEPTION, а емульовані prepare вимкнено — збої викидають PDOException / RuntimeException зі справжнім повідомленням драйвера.
Кілька з’єднань
DatabaseManager тримає іменовані з’єднання лінивими:
use Lift\Database\DatabaseManager;
$db = DatabaseManager::fromConfig([
'default' => 'main',
'connections' => [
'main' => ['driver' => 'mysql', 'host' => '...', 'database' => 'app'],
'analytics' => ['driver' => 'pgsql', 'host' => '...', 'database' => 'analytics'],
'cache_db' => ['driver' => 'sqlite', 'database' => '/tmp/cache.sqlite'],
],
]);
$users = $db->table('users')->get(); // за замовчуванням = main
$events = $db->table('events', 'analytics')->count();
Лише перший виклик table('…', 'analytics') відкриває другий PDO.
2. Конструктор запитів — читання
Почніть запит із $db->table('foo'). Кожен метод повертає $this, тож зчіплюйте їх.
$users = $db->table('users')
->select('id', 'name', 'email')
->where('active', 1)
->where('age', '>=', 18)
->orderBy('name')
->limit(20)
->get(); // [['id' => 1, …], …]
Вибірка
->select('id', 'name')
->addSelect('email') // додати ще стовпці
->distinct()
За замовчуванням SELECT *.
Where-клаузи
->where('status', 'active') // status = 'active' (2-аргументна форма)
->where('age', '>=', 18) // age >= 18
->where('name', 'LIKE', 'Al%')
->orWhere('status', 'pending')
->whereIn ('id', [1, 2, 3])
->whereNotIn('id', $bannedIds)
->whereNull ('deleted_at')
->whereNotNull('verified_at')
->whereBetween('age', 18, 65)
->whereRaw('json_extract(meta, "$.role") = ?', ['admin'])
where('column', null) — це скорочення для whereNull('column'). Підтримувані оператори — =, <, >, <=, >=, <>, !=, LIKE, NOT LIKE, ILIKE — некоректні викидають InvalidArgumentException (що запобігає SQL-ін’єкції через аргумент оператора).
Ніколи не інтерполюйте користувацький ввід в імена стовпців/таблиць. Значення автоматично стають прив’язаними параметрами; ідентифікатори проходять через
Grammar::wrap(). Звичайні імена (users,u.name) екрануються; усе інше трактується як сирий вираз, щобCOUNT(*)і псевдоніми продовжували працювати.Починаючи з 1.2.1:
Grammar::wrap()відхиляє сирий вираз, що містить роздільник операторів (;), SQL-коментар (--,/* */), NUL-байт або переведення рядка, зInvalidArgumentException. Це ловить поширену помилку передавання користувацького вводу як імені стовпця чиorderBy()— але це страхувальна сітка, а не заміна валідації ідентифікаторів за вашим власним списком дозволених.Починаючи з 1.3.0:
update()іdelete()відмовляються виконуватися безWHERE. Для навмисної операції по всій таблиці спочатку викличтеallowMassUpdate(),allowMassDelete()абоallowMassMutation().
JOIN'и
$db->table('orders')
->select('orders.id', 'orders.total', 'users.email')
->join ('users', 'orders.user_id', '=', 'users.id')
->leftJoin ('addresses', 'orders.address_id', '=', 'addresses.id')
->rightJoin('payments', 'orders.id', '=', 'payments.order_id')
->where('orders.status', 'paid')
->get();
Групування / сортування / посторінковий вивід
->groupBy('status', 'country')
->having('count', '>', 5)
->orderBy('created_at', 'DESC')
->orderByDesc('id')
->latest('created_at') // ORDER BY created_at DESC
->oldest('created_at') // ORDER BY created_at ASC
->limit(20)
->offset(40)
->take(20) // псевдонім для limit
->skip(40) // псевдонім для offset
Отримання
->get(); // масив рядків
->first(); // перший рядок або null
->value('email'); // одиночний скаляр із першого рядка
->pluck('email'); // масив одного стовпця з усіх збіглих рядків
->exists(); // bool
->doesntExist(); // bool
->count(); // int
->count('email'); // підрахунок не-null email
->sum('amount');
->avg('rating');
->min('price');
->max('price');
Подивитися SQL, не виконуючи його
$sql = $db->table('users')->where('active', 1)->toSql(); // рядок
$bindings = $db->table('users')->where('active', 1)->getBindings(); // [1]
Чудово для налагодження й написання тестів, які не звертаються до БД.
3. Конструктор запитів — запис
// INSERT — повертає останній вставлений ID (string|false)
$id = $db->table('users')->insert([
'name' => 'Alice',
'email' => '[email protected]',
]);
// Пакетний INSERT — один round-trip, без поверненого значення
$db->table('logs')->insertMany([
['level' => 'info', 'msg' => 'one'],
['level' => 'error', 'msg' => 'two'],
]);
// UPDATE — повертає кількість зачеплених рядків
$db->table('users')
->where('id', 42)
->update(['name' => 'Bobby', 'updated_at' => date('Y-m-d H:i:s')]);
// DELETE — повертає кількість зачеплених рядків
$db->table('sessions')->where('expires_at', '<', date('Y-m-d H:i:s'))->delete();
Виклик update() / delete() без будь-якого where() зачепить кожен рядок. Завжди перевіряйте.
4. Пагінація
$page = $db->table('posts')
->where('published', 1)
->orderBy('created_at', 'DESC')
->paginate(page: 2, perPage: 15, path: '/posts');
return Response::json($page);
Повертає Paginator, що реалізує JsonSerializable, тож передавання його в Response::json() виробляє:
{
"data": [ /* 15 рядків */ ],
"total": 324,
"per_page": 15,
"current_page": 2,
"last_page": 22,
"from": 16,
"to": 30
}
Інші методи:
$page->items(); // сирий масив рядків
$page->total();
$page->currentPage();
$page->lastPage();
$page->hasMorePages();
$page->onFirstPage();
$page->links(); // проста HTML-панель пагінації з «Prev / 1 2 … / Next»
$page->links() навмисно мінімальний — рендеріть власний HTML, якщо хочете вишуканіший контрол.
5. Чанкінг — великі набори результатів
Коли ви не можете завантажити все в ОЗП:
$db->table('users')
->orderBy('id')
->chunk(500, function (array $rows, int $page) use ($mailer) {
foreach ($rows as $row) {
$mailer->send($row['email'], 'Newsletter');
}
// поверніть false, щоб зупинитися раніше
});
Lift завантажує по 500 рядків за раз і викликає ваш колбек. Тримайте orderBy('id') (або інший стабільний стовпець) — інакше ви пропускатимете / переоброблятимете рядки, коли БД переупорядкує результати.
cursor() — потокова ітерація
cursor() повертає генератор, який вибирає по одному рядку за раз з бази даних. На відміну від chunk(), колбека немає — використовуйте звичайний foreach. Пам’ять залишається майже постійною незалежно від розміру таблиці.
foreach ($db->table('events')->where('processed', 0)->orderBy('id')->cursor() as $row) {
processEvent($row);
}
Коли віддати перевагу cursor() над chunk():
chunk() |
cursor() |
|
|---|---|---|
| API | на основі колбека | генератор foreach |
| Пам’ять на крок | N рядків (розмір чанка) | 1 рядок |
| Ранній вихід | return false у колбеку |
break у foreach |
| Підходить, коли | потрібні операції на рівні чанка | построковий стримінг |
6. Транзакції
Форма із замиканням рекомендована — вона фіксує за успіху й відкочує за будь-якого винятку:
$id = $db->transaction(function (Connection $db) {
$orderId = $db->table('orders')->insert([
'user_id' => 42,
'total' => 100.0,
]);
$db->table('items')->insertMany([
['order_id' => $orderId, 'sku' => 'A1', 'qty' => 1],
['order_id' => $orderId, 'sku' => 'B2', 'qty' => 2],
]);
return $orderId;
});
Ручна форма, коли потрібен тонший контроль:
$db->beginTransaction();
try {
// …
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
$db->inTransaction(); // bool
7. Песимістичне блокування
Коли два процеси можуть змагатися за ті самі рядки (черга, лічильник, …):
$db->transaction(function (Connection $db) {
$job = $db->table('jobs')
->where('status', 'pending')
->orderBy('id')
->forUpdate(skipLocked: true) // FOR UPDATE SKIP LOCKED (mysql 8 / pg)
->first();
if ($job !== null) {
$db->table('jobs')->where('id', $job['id'])->update(['status' => 'running']);
}
});
| Метод | SQL |
|---|---|
forUpdate() |
... FOR UPDATE |
forUpdate(skipLocked: true) |
... FOR UPDATE SKIP LOCKED |
sharedLock() |
... FOR SHARE (PG) / LOCK IN SHARE MODE (MySQL) |
На SQLite клауза блокування мовчки опускається — SQLite усе одно блокує всю БД під час транзакції запису.
8. Advisory (іменовані) блокування
Для «лише один процес має виконувати це за раз» без блокувань таблиць:
// Блокувати до отримання або таймауту в секундах
$db->withAdvisoryLock('daily-report', function (Connection $db) {
generateReport($db);
}, timeout: 30);
// Вручну
if ($db->advisoryLock('export', timeout: 10)) {
try {
// …
} finally {
$db->advisoryUnlock('export');
}
}
Підтримується на MySQL і PostgreSQL. SQLite викидає RuntimeException.
9. Сирі запити
Коли конструктор не може виразити те, що вам потрібно:
$rows = $db->select ('SELECT * FROM users WHERE id IN (?, ?, ?)', [1, 2, 3]);
$row = $db->selectOne('SELECT * FROM users WHERE id = ?', [42]);
$count = $db->value ('SELECT COUNT(*) FROM users WHERE active = ?', [1]);
$affected = $db->execute(
'UPDATE users SET last_seen = ? WHERE id = ?',
[time(), 42],
);
$lastId = $db->lastInsertId();
Завжди передавайте користувацький ввід як прив’язані параметри (плейсхолдери ? + масив $bindings). Ніколи "WHERE id = " . $_GET['id'].
10. Слухач запитів (налагодження / метрики)
Підпишіться на кожен виконаний запит:
$db->onQuery(function (string $sql, array $bindings, float $ms): void {
error_log(sprintf('[%.1f ms] %s | %s', $ms, $sql, json_encode($bindings)));
});
Панель налагодження використовує це внутрішньо. Сповіщення про повільні запити — два рядки зверху.
11. Схема та міграції
Дві частини:
Schema— помічник DDL (CREATE / ALTER / DROP).Migrator— версіонований раннер змін.
Схема (без міграцій)
use Lift\Database\Schema\Schema;
$schema = new Schema($db);
$schema->create('users', function ($table) {
$table->id();
$table->string('email', 200)->unique();
$table->string('password');
$table->boolean('active')->default(true);
$table->json('settings')->nullable();
$table->timestamps(); // created_at + updated_at
});
$schema->alter('users', function ($table) {
$table->string('avatar_url', 500)->nullable();
});
$schema->dropIfExists('old_table');
$schema->rename('users_v1', 'users');
$schema->hasTable('users'); // bool
$schema->hasColumn('users', 'email');
Типи стовпців
$table->id(); $table->bigIncrements('order_id');
$table->string($n, 200); $table->char($n, 32);
$table->text($n); $table->mediumText($n); $table->longText($n);
$table->integer($n); $table->bigInteger($n); $table->smallInteger($n);
$table->tinyInteger($n);$table->decimal($n, 10, 2);
$table->float($n); $table->double($n);
$table->boolean($n); $table->binary($n);
$table->date($n); $table->dateTime($n); $table->time($n);
$table->timestamp($n); $table->timestamps(); // created_at + updated_at
$table->softDeletes(); // deleted_at (nullable)
$table->json($n);
$table->uuid($n);
$table->enum($n, ['admin','user']);
$table->foreignId($n); // беззнакове big int, придатне для FK
Модифікатори на стовпець:
->nullable()
->default($value)
->index()
->unique()
->primary()
->after('email') // лише MySQL
->foreign('users', 'id') // FK → users.id (налаштовує обмеження)
->onDelete('cascade')->onUpdate('cascade')
Міграції
Кожна міграція — це один PHP-файл, що повертає екземпляр Migration:
// database/migrations/2025_05_14_120000_create_posts_table.php
use Lift\Database\Migration;
use Lift\Database\Schema\Schema;
return new class($db) extends Migration {
public function up(): void
{
(new Schema($this->db))->create('posts', function ($t) {
$t->id();
$t->string('title');
$t->text('body');
$t->foreignId('user_id')->index();
$t->timestamps();
});
}
public function down(): void
{
(new Schema($this->db))->dropIfExists('posts');
}
};
Ім’я файлу (без .php) стає іменем міграції і тим, що зберігається в таблиці migrations.
Запустіть їх:
use Lift\Database\Migrator;
$migrator = new Migrator($db, __DIR__ . '/../database/migrations');
$migrator->migrate(); // виконати всі очікувані — повертає масив імен
$migrator->rollback(); // відкотити останній пакет
$migrator->rollback(3); // відкотити останні 3 пакети
$migrator->reset(); // відкотити все
$migrator->fresh(); // reset + migrate (почати заново)
$migrator->status(); // [['migration'=>'...','ran'=>bool,'batch'=>int|null], …]
Через CLI (vendor/bin/lift):
lift make:migration create_posts_table
lift migrate
lift migrate:rollback
lift migrate:status
lift migrate:fresh
Migrator створює таблицю migrations під час першого запуску — окремого кроку налаштування немає.
Найкраща практика: тримайте міграції лише доповнюваними в main. Ніколи не редагуйте міграцію, яку вже задеплоєно; пишіть нову.
12. Модель (active record)
Опційна тонка обгортка. Повністю пропустіть цей розділ, якщо віддаєте перевагу стилю конструктора запитів.
use Lift\Database\Model;
final class User extends Model
{
protected static string $table = 'users';
protected static string $primaryKey = 'id';
protected array $fillable = ['name', 'email', 'role'];
}
User::setConnection($db);
// CRUD
$user = User::find(1);
$user = User::create(['name' => 'Alice', 'email' => '[email protected]']);
$user->set('name', 'Updated')->save();
$user->delete();
// Запит
$users = User::query()->where('active', 1)->get(); // сирі рядки
$active = User::query()->where('active', 1)->first();
// Пакетно
foreach ($users as $row) {
$user = User::hydrate($row); // обгорнути рядок у Model без повторного запиту
}
// Відстеження змін
$user->set('name', 'X');
$user->dirty(); // ['name' => 'X']
$user->save(); // оновлює лише `name`
Безпека масового присвоєння
Або список дозволених ($fillable), або список заборонених ($guarded) — ніколи обидва:
protected array $fillable = ['name', 'email']; // ЛИШЕ ці доступні для масового присвоєння
// АБО (взаємовиключно):
protected array $guarded = ['id', 'is_admin']; // усе інше ДОСТУПНЕ для масового присвоєння
Виклик new User($request->json()) копіює лише дозволені ключі; решта мовчки відкидаються.
Касти атрибутів
Оголосіть $casts, щоб автоматично перетворювати сирі значення бази даних на типізовані значення PHP.
final class Post extends Model
{
protected static string $table = 'posts';
protected array $fillable = ['title', 'body', 'meta', 'published', 'published_at'];
protected array $casts = [
'published' => 'bool',
'view_count' => 'int',
'score' => 'float',
'meta' => 'json', // масив ↔ рядок JSON
'published_at' => 'datetime', // рядок ↔ DateTimeImmutable
'expires_on' => 'date', // лише частина з датою
'expires_ts' => 'timestamp', // Unix int ↔ DateTimeImmutable
];
}
Кастинг відбувається прозоро:
$post = Post::find(1);
$post->get('published'); // bool — не "1" / "0"
$post->get('meta'); // ['key' => 'val'] — не '{"key":"val"}'
$post->get('published_at'); // DateTimeImmutable — не '2026-05-15 10:00:00'
// Запис — серіалізує назад автоматично
$post->set('meta', ['theme' => 'dark']); // зберігається як '{"theme":"dark"}'
$post->set('published_at', new \DateTimeImmutable('now')); // зберігається як '2026-05-15 …'
$post->save();
// toArray() і вивід JSON також відображають касти
Response::json($post); // 'meta' — об’єкт, 'published' — true/false
Підтримувані типи кастів:
| Тип | Тип читання PHP | Серіалізація запису |
|---|---|---|
int / integer |
int |
— |
float / double |
float |
— |
string |
string |
— |
bool / boolean |
bool |
— |
array / json |
array |
json_encode() |
datetime |
DateTimeImmutable |
Y-m-d H:i:s |
date |
DateTimeImmutable (північ) |
Y-m-d H:i:s |
timestamp |
DateTimeImmutable |
Unix int |
Значення null пропускаються без кастингу.
Локальні області (scopes)
Визначте методи scope{Name}, щоб об’єднати повторно використовувані фільтри:
class Post extends Model
{
public function scopePublished(QueryBuilder $q): void
{
$q->where('published', 1)->whereNotNull('published_at');
}
}
Post::published()->where('author_id', 7)->get(); // викликає scopePublished, потім зчіплює
Відношення
Використовуйте помічники зсередини методів моделі, які ви визначаєте самі:
class User extends Model
{
public function posts(): array { return $this->hasMany(Post::class); }
public function profile(): ?Profile { return $this->hasOne(Profile::class); }
}
class Post extends Model
{
public function user(): ?User { return $this->belongsTo(User::class); }
}
$user = User::find(1);
foreach ($user->posts() as $post) { … }
Багато-до-багатьох через зведену таблицю:
class User extends Model
{
// зведена таблиця: role_user (алфавітний порядок, автовиведено)
// стовпці: user_id, role_id
public function roles(): array { return $this->belongsToMany(Role::class); }
}
class Role extends Model
{
public function users(): array { return $this->belongsToMany(User::class); }
}
$user = User::find(1);
$roles = $user->roles(); // Role[]
Власні імена зведеної таблиці або зовнішніх ключів:
// belongsToMany(related, pivotTable, thisFk, relatedFk)
$this->belongsToMany(Role::class, 'user_roles', 'uid', 'rid');
Ім’я зведеної таблиці за замовчуванням — це два snake_case-імені моделі в алфавітному порядку: User ↔ Role → role_user, Post ↔ Tag → post_tag.
Помічники виконують окремий запит на кожен виклик — нормально для простого випадку, але стережіться N+1 у циклах. Для патерну N+1 опустіться до сирого join:
$rows = $db->table('posts')
->join('users', 'posts.user_id', '=', 'users.id')
->select('posts.*', 'users.email as author_email')
->get();
Події життєвого циклу моделі
Під’єднуйтеся до створення / оновлення / видалення через диспетчер подій:
use Lift\Database\Events\ModelCreating;
Model::setEventDispatcher($app->events());
$app->events()->listen(ModelCreating::class, function (ModelCreating $e) {
if ($e->model instanceof User && empty($e->model->get('uuid'))) {
$e->model->set('uuid', Uuid::v7());
}
});
Події Model{Creating, Created, Updating, Updated, Deleting, Deleted}. *ing-події перервні — викличте $e->stopPropagation(), щоб скасувати.
М’яке видалення
Опційний трейт. Встановлює deleted_at замість видалення; автоматично обмежує запити для виключення м’яко видалених:
use Lift\Database\Model;
use Lift\Database\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
protected static string $table = 'posts';
}
$post = Post::find(1);
$post->delete(); // встановлює deleted_at; рядок залишається
Post::find(1); // null — м’яко видалене виключено
Post::withTrashed()->get(); // включити м’яко видалені
Post::onlyTrashed()->get(); // лише м’яко видалені
$post->restore(); // очистити deleted_at
$post->forceDelete(); // остаточно DELETE FROM …
$post->trashed(); // bool
Не забудьте додати стовпець у вашу міграцію:
$table->softDeletes(); // додає nullable-мітку часу `deleted_at`
13. Вивід JSON
І Model, і Paginator реалізують JsonSerializable. Поверніть їх з обробника, і Lift автоматично загорне їх у JSON:
$app->get('/users/{id}', fn($req) => User::find((int) $req->param('id')));
// → 200 JSON або 204, якщо модель null
Щоб сформувати вивід (приховати паролі, перейменувати поля), загорніть у JsonResource.
Часті підводні камені
| Симптом | Причина | Виправлення |
|---|---|---|
Database connection failed: SQLSTATE[…] під час завантаження |
Невірний DSN / БД недоступна | Роздрукуйте getMessage(), перевірте облікові дані, мережу. |
Invalid WHERE operator: [contains] |
Використано не-SQL оператор | Дотримуйтеся переліку операторів; використовуйте LIKE для підрядка. |
| Update зачіпає 0 рядків, хоча я очікував 1 | Ваш where() не збігся |
Перевірте ідентифікатори; приведіть типи ((int)$id). |
| Пакетна вставка мовчки нічого не робить | Порожній масив | insertMany([]) — це no-op; фреймворк не видає помилку. |
Величезний UPDATE виконався без WHERE |
Ви забули ->where(...) |
Lift тепер відмовляється виконувати це за замовчуванням; додайте where або явний allowMassUpdate(). |
N+1 запити (один на ітерацію циклу) |
Model::hasMany() усередині циклу |
Використовуйте один JOIN або передзавантажте ідентифікатори й згрупуйте вручну. |
| Порядок міграцій випадковий | Порядок файлової системи не гарантований | Lift сортує файли за іменем — завжди префіксуйте міткою часу YYYY_MM_DD_HHMMSS_. |
lastInsertId() повертає 0 |
PostgreSQL + немає послідовності | Використовуйте RETURNING id через selectOne або задайте послідовність. |
Шпаргалка
// Підключення
$db = Connection::fromConfig([...]);
// Читання
$rows = $db->table('users')->where('active', 1)->orderBy('id')->get();
$one = $db->table('users')->where('id', 42)->first();
// Запис
$id = $db->table('users')->insert([...]);
$db->table('users')->where('id', $id)->update([...]);
$db->table('users')->where('id', $id)->delete();
// Транзакція
$db->transaction(fn($db) => /* … */);
// Пагінація
$page = $db->table('users')->paginate(1, 20, '/users');
// Стримінг по одному рядку за раз (постійна пам’ять)
foreach ($db->table('events')->cursor() as $row) { … }
// Сирий
$rows = $db->select('SELECT … WHERE x = ?', [$v]);
// Схема
(new Schema($db))->create('t', fn($t) => $t->id());
// Міграція
(new Migrator($db, __DIR__ . '/db/migrations'))->migrate();
// Модель
class User extends Model {
protected static string $table = 'users';
protected array $fillable = ['name', 'email'];
protected array $casts = ['active' => 'bool', 'meta' => 'json', 'created_at' => 'datetime'];
}
User::setConnection($db);
$user = User::find(1);
$user->roles(); // belongsToMany(Role::class) — через зведену таблицю role_user