topthink/framework
ThinkPHP is a fast, simple MVC PHP framework for web development, offering routing, ORM, caching, templating, and a rich toolkit for building applications quickly. This package is its core framework.
ThinkPHP is a full-stack MVC framework; topthink/framework is the core runtime (container, HTTP kernel, router, middleware pipeline, config, events, logging, cache/session managers, validation). The ORM/model layer comes from topthink/think-orm, templating from topthink/think-view + think-template, and multi-app / tracing / captcha each ship as separate topthink/* packages.
Do not install the core into an empty project. Use the application skeleton, which pins a compatible framework version plus the app/, config/, route/ scaffolding:
composer create-project topthink/think tp
cd tp && php think run # http://localhost:8000
If you are wiring your own bootstrap, the entry point is public/index.php:
require __DIR__ . '/../vendor/autoload.php';
$http = (new \think\App())->http;
$response = $http->run();
$response->send();
$http->end($response);
Look here first, in this order:
public/index.php — kernel boot.app/ — BaseController, middleware.php, provider.php, event.php, service.php, common.php.route/app.php — where requests land.config/app.php, config/database.php, .env (loaded into env() / Config).think\App, think\Http, think\Route, think\Middleware, think\Request. They explain ~80% of framework behavior.First use case — a route, a controller, a DB query:
// route/app.php
use think\facade\Route;
Route::get('hello/:name', function ($name) { return 'Hello, ' . $name; });
Route::get('users', 'User/index');
// app/controller/User.php
namespace app\controller;
use app\BaseController;
use think\facade\Db;
class User extends BaseController
{
public function index()
{
return json(Db::name('user')->where('status', 1)->order('id', 'desc')->paginate(15));
}
}
Scaffold the rest instead of typing it: php think make:controller Blog, make:model Blog, make:middleware Auth, make:validate Blog, make:command Sync.
Routing: groups, middleware, resources. Route files are plain PHP returning nothing — you call Route::get/post/rule for their side effects. Attach middleware at the group level rather than repeating it:
Route::group('api/v1', function () {
Route::get('posts/:id', 'Post/read')->pattern(['id' => '\d+']);
Route::resource('posts', 'Post'); // index/create/save/read/edit/update/delete
})->middleware(\app\middleware\Auth::class);
Route::miss(fn() => json(['code' => 404], 404));
Middleware is the primary extension seam. One class, one method — keep it thin and let the controller own business logic:
class Auth
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->header('authorization')) {
return json(['code' => 401], 401);
}
$request->uid = 1; // stash context on the request
return $next($request);
}
}
Register global middleware in app/middleware.php; route/controller-level middleware is appended after the globals.
Two data-access styles — pick deliberately. Db::name() for reporting/aggregation and bulk work; models for anything with behavior:
Db::name('order')->whereBetween('created_at', $range)->group('status')->column('count(*)', 'status');
Db::transaction(function () use ($data) { Order::create($data); Stock::where('id', 1)->dec('qty')->update(); });
class Order extends \think\Model
{
use \think\model\concern\SoftDelete;
protected $autoWriteTimestamp = true;
public function items() { return $this->hasMany(OrderItem::class); }
}
Order::with(['items'])->where('user_id', $uid)->select();
Validation lives beside the controller, not inside it. Use a dedicated Validate class with scenes so create/update rules stay separate:
$validate = Validate::rule([
'title' => 'require|max:100',
'email' => 'require|email|unique:user',
])->message(['title.require' => '标题必填']);
if (!$validate->scene('create')->check($data)) {
return json(['msg' => $validate->getError()], 422);
}
Services for anything reusable. A think\Service gives you register() (bindings, before boot) and boot() (runs after all providers are loaded — safe for event listeners and route macros). List it in app/service.php; interface bindings go in app/provider.php.
Events decouple side effects (mail, audit, cache invalidation) from controllers: register in app/event.php, fire with Event::trigger('order.paid', $order), listen with Event::listen('order.paid', SendReceipt::class).
Multi-app for admin/api splits: composer require topthink/think-multi-app, then app/index, app/admin, each with its own controller/, route/, middleware.php. Route files move under the app directory — forgetting this is the usual "routes stopped working" cause.
Config and env discipline. .env → env('DB_HOST'); everything else → config('database.connections.mysql.host'). Never call env() outside config files if you plan to enable config caching.
php think optimize (the consolidated command introduced in 8.1.4) or the granular optimize:config / optimize:route / optimize:schema write into runtime/; after changing config/, .env or the schema you must php think clear. The schema cache is the nastiest: a newly added column returns null until cleared.Db::name() applies the table prefix, Db::table() does not. Mixing them produces "table not found" in production where the prefix differs from dev.Request::param() merges route, get and post variables and applies the global filter. When precedence matters, be explicit: $request->get(), $request->post(), $request->route().App::initialize(). In PHPUnit, queue workers, or standalone scripts you must bootstrap: $app = new \think\App(); $app->initialize(); before touching Db, Config, or Cache.paginate() needs a live request object to build links; in CLI/jobs call paginate(15, false) or use ->each(), otherwise you get an exception or a bogus page size.$next($request) must be returned, not just called. A middleware that returns nothing yields an empty response with a 200. If you pass parameters through group middleware (->middleware(Auth::class, 'admin')), keep the framework at ≥ 8.1.4 — earlier 8.1.x had argument-forwarding bugs there.url_convert on (default), paths are lowercased; CamelCase controllers must be addressed as camel_case. This bites hardest when porting REST clients between ThinkPHP and other frameworks.Route::rule('blog/:id', 'Blog/read') still resolves when you configure a controller_suffix. Verify generated URLs after enabling a suffix — URL generation was itself patched in 8.1.4, so keep topthink/framework locked to a single 8.1.x patch level rather than a floating ^8.1.find() returns null, findOrFail() throws — pick per layer, and let app/ExceptionHandle.php shape the JSON error envelope rather than try/catching everywhere.Db::name() calls. Bulk update() skips model events entirely.app_debug=true in .env gives verbose exception pages; topthink/think-trace adds the bottom-of-page panel (queries, timing, loaded files); dump()/trace() helpers work anywhere. Turn both off in production.think\Service::boot(), route macros (Route::macro()), custom think\Facade subclasses with getFacadeClass(), custom validators, custom console commands in config/console.php, and container bindings in app/provider.php. think\Config's hook/lazy hooks gained a key argument in 8.1.4 — relevant only if you subclass or hook Config directly.psr/http-message ^2.0 is now allowed, which unblocks Composer conflicts with PSR-7/PSR-17 libraries that already require v2 — worth upgrading for that alone if you interop with a PSR-7 stack.Response::send() and the HttpEnd error-logging path were hardened in 8.1.4; if you wrap $http->run() in your own try/catch (custom entry point, Swoole worker), upgrade rather than patching around the old exception swallowing.NOASSERTION, while the project is Apache-2.0. Run your own license scan before shipping into a compliance-gated codebase.think\Facade helpers) in ways that break copy-pasted snippets, and even within 8.1 the router URL generation and middleware argument handling changed.topthink/think, not the core directly. Don't read it as a signal of adoption.How can I help you explore Laravel packages today?