topthink/think-orm
ThinkORM is the PHP ORM behind ThinkPHP, offering ActiveRecord-style models, a fluent query builder, relationships, events, soft deletes, and multi-database support. Use it with ThinkPHP or standalone for expressive, efficient database access.
topthink/think-orm is a standalone PHP ORM/query-builder that also powers ThinkPHP. It can be used outside the framework with a small bootstrap.
composer require topthink/think-orm
Minimal bootstrap (no ThinkPHP app needed):
use think\facade\Db;
Db::setConfig([
'default' => 'mysql',
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'app',
'username' => 'root',
'password' => '',
'hostport' => '3306',
'charset' => 'utf8mb4',
'prefix' => '',
],
],
]);
$user = Db::name('user')->where('id', 1)->find();
Start with two entry points:
think\facade\Db — query builder for reports, migrations, bulk operations, and anything where you want explicit SQL-like control.think\Model — active-record models for domain entities, relations, events, casts, and serialization.First useful use case: replace hand-written PDO with Db::name('table') for reads and a single Model subclass for writes. Then add relations and eager loading.
Where to look first:
think\db\Query for available builder methods (where, whereIn, whereRaw, field, order, page, chunk, cursor, etc.).think\Model and think\model\concern\* for attributes, casting, soft deletes, and serialization.think\db\connector\* for connection-specific behavior.Use the builder for ad-hoc queries and reporting:
$rows = Db::name('orders')
->field('status, COUNT(*) AS total')
->whereBetween('created_at', [$start, $end])
->group('status')
->select();
Use models when behavior matters:
class User extends \think\Model
{
protected $name = 'user'; // table without prefix
protected $pk = 'id';
protected $autoWriteTimestamp = 'datetime';
protected $type = [
'id' => 'integer',
'config' => 'json',
];
public function posts()
{
return $this->hasMany(Post::class, 'user_id', 'id');
}
}
$user = User::find(1);
$user->name = 'Alice';
$user->save();
Avoid N+1 by using with() and withCount():
$users = User::with([
'posts' => fn($q) => $q->where('published', 1)->limit(5),
])->withCount('posts')->select();
foreach ($users as $user) {
echo $user->posts_count;
}
Use hasWhere() / has() when filtering parents by relation existence:
User::hasWhere('posts', ['published' => 1])->select();
Prefer the closure form so rollback happens automatically:
Db::transaction(function () use ($data) {
$order = Order::create($data);
OrderItem::saveAll($data['items']);
return $order;
});
For manual control use Db::startTrans(), Db::commit(), Db::rollback(). Nested transactions use savepoints where supported.
chunk() and cursor()chunk() is the standard approach for large updates, but sort by a stable, immutable key:
Db::name('user')->where('synced', 0)->chunk(500, function ($users) {
foreach ($users as $user) {
// process
}
});
cursor() streams rows with lower memory but holds the connection open longer; don’t run concurrent queries on the same connection inside the loop.
Model lifecycle hooks are where you put cross-cutting behavior:
class User extends \think\Model
{
protected $event = [
'before_insert' => 'hashPassword',
'after_delete' => 'purgeCache',
];
}
Use observer classes for anything non-trivial. Keep events idempotent — they fire on save(), create(), delete(), and relation saves, but not on insertAll() / saveAll() in every version, so don’t rely on them for bulk paths.
protected $json = ['config'];
protected $jsonAssoc = true;
use \think\model\concern\SoftDelete;
protected $deleteTime = 'delete_time';
protected $defaultSoftDelete = null;
Then:
User::withTrashed()->find(1)->restore();
User::onlyTrashed()->select();
User::destroy(1); // soft delete
Hide or expose fields at serialization time:
$user->hidden(['password'])->toArray();
$user->append(['full_name']);
In a framework context paginate() just works. Standalone, either bind a request/page source or drive it manually:
$page = max(1, (int)($_GET['page'] ?? 1));
$size = 20;
$total = Db::name('user')->count();
$rows = Db::name('user')->page($page, $size)->select();
Use paginate($size, false, ['page' => $page]) when you want the Paginator object without the framework’s request resolver.
name() vs table(): Db::name('user') applies the configured table prefix; Db::table('user') does not. Mixing them is the most common source of “table not found” errors.find() returns null when no row matches. Use findOrFail() if you want an exception; don’t call ->save() on a null result.whereOr grouping: chained whereOr calls can leak out of an intended group. Use a closure: ->where(function ($q) { $q->where(...)->whereOr(...); }).where('col', null) compiles to IS NULL. If you need = NULL semantics you’re probably looking for whereNull() / whereNotNull().whereIn with an empty array typically compiles to a false condition (0 = 1). Guard against empty ID lists to avoid confusing SQL.$autoWriteTimestamp explicitly ('int', 'datetime', or 'timestamp'). A mismatch between int columns and datetime casting produces silently wrong values.insertAll() / saveAll() skip model events and may skip automatic timestamps depending on version. For audit-critical writes, loop create() or set timestamps manually.save() returning true does not always mean a query ran — an unchanged model may short-circuit. Use getChangedData() or check isExists() when behavior depends on it.update([]) throws. Guard with if (empty($data)) return;.chunk() with a mutable sort column: updating the field you ordered by can skip or duplicate rows. Order by primary key or a frozen column.->cache(true, 60) silently behaves differently or errors if no cache driver is bound; configure think\facade\Cache before relying on it.$query->getLastSql() works per query instance; Db::listen(fn($sql, $time, $explain) => ...) gives global visibility. fetchSql(true) returns the SQL string instead of executing.Db::close() or reconnect between jobs to avoid stale connections, “server has gone away,” and transaction state leaking across requests.deploy, rw_separate, master_num, and slave_no. Reads may hit a replica immediately after a write; use ->master(true) when consistency matters.$field / $schema can block mass assignment. If a field silently won’t save, check the model’s allowed-field definitions before debugging the database.withTrashed() for admin views and remember destroy() is not a hard delete unless you call force()->delete().whereRaw('id > ?', [$id]) — never interpolate user input.Db::setConfig() before the first query. If you change config at runtime, close the existing connection so the new settings are picked up.How can I help you explore Laravel packages today?