dvaknheo / duckadmin
a backend by duckphp
Requires
- php: >=7.4.0
- dvaknheo/duckcoverage: ^1.0.2
- dvaknheo/duckphp: ^1.4.1
- illuminate/database: ^11.0
- topthink/think-orm: ^4.0
Requires (Dev)
- phpunit/php-code-coverage: ^9.2
- phpunit/phpunit: ^9.6
Suggests
- illuminate/database: for Admin_LaravelModel(^11.0)
- topthink/think-orm: for Admin_ThinkPHPModel(^4.0)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-20 12:57:52 UTC
README
English | 中文
Security (must read before deployment)
DuckAdmin serves nothing by default. You must explicitly turn it on in each environment's config/DuckPhpSettings.config.php:
return [ // ... 'duckadmin_enable' => true, ];
- Without this key, or with it set to
false: every web request is blocked and only a notice page is shown (HTTP 503), telling you to enable it here. Even the install page is blocked, so there is no "I forgot to turn it on and someone else installed it" scenario. - Being off by default is deliberate: this is the switch that says "I mean to expose the admin backend", and one less config item is one less risk.
- The command line is not exempt any more:
checkEnabled()used to return early for CLI, and no longer does, so with the switch offphp bin/cli.php(includingcover) stops at the same notice page. Turn the switch on in the settings before running CLI commands. - If the notice page is still there after your change, wait a second or two and refresh (PHP's
opcache.revalidate_freqdefaults to 2 seconds, and this config file gets cached); restart php-fpm if necessary.
Introduction
You have surely seen many admin systems that are built by doing secondary development on top of a baseline project. But that does not match the thinking of DuckPhp.
DuckAdmin is a library that uses the DuckPhp framework; your project uses DuckPhp's features, no hacky rewriting needed.
DuckAdmin is a baseline library for secondary development — note, a baseline library, not a baseline project.
DuckAdmin only provides the basic code for a company's employee structure and generic business logic. You can add more features that fit your company's business on top of it. It even comes with a coverage testing library.
What problems does DuckAdmin solve
- Today's admin backends are customized by changing code in someone else's admin backend, but what makes DuckAdmin stand out is that it is a baseline library, not a baseline project
- Static resources and code are separated, so they can be deployed on different machines
- This composer library includes not only DuckAdmin but also four independent projects — DuckUser, DuckUserManager, SimpleBlog — plus a standalone demo project, DuckAdminDemo.
- DuckUser is a user baseline library, and you can modify it from the outside in the same way as DuckAdmin
- DuckUserManger is the user management system
- SimpleBlog SimpleBlog demonstrates a blog system built on the management system and user system above.
- It even includes a coverage testing library. DuckCoverage, for doing coverage testing
Demo
Extra requirement: sqlite
DuckAdmin supports mysql too; you only need to change the config, as demonstrated later
composer require dvaknheo/duckadmin
cd ./vendor/dvaknheo/duckadmin/demo
php cli.php run
Visit http://127.0.0.1:8080/ to open the Demo
Reinstalling
In the demo
Your data is saved in vendor/dvaknheo/duckadmin/demo/runtime/database.db
The dynamic config file is saved in vendor/dvaknheo/duckadmin/demo/config/DuckPhpApps.config.php
Delete these two, and you can run the installer below again.
php cli.php install ## --force forces it --help shows the parameters
Reinstalling via the Web installer (changing the database config)
Once the sub-application is installed, visiting <prefix>/install directly will 302 back to the home page (framework behavior, to stop accidental visits to the install page).
There is no force=1 escape hatch any more: DuckAdmin\System\WebInstaller was removed and the
framework's own installer class is used, so the only way back is to make the application "not installed" again.
To reinstall (for example to switch the database file or the admin/user provider): delete that application's data
config (runtime/DuckPhpData*.config.json) and its database file, then visit <prefix>/install again. (In the demo
the CLI installer php cli.php install --force also works.)
Admin / user providers are switched with the radio buttons on the root application's home page (written to runtime/DuckPhpData*.config.json);
after switching, run the reinstall above once for the corresponding sub-application, so its database config is initialized again.
Normal usage modes
Mode one: a quick duckphp project plus application.
When you want to copy the demo example and do nothing else, run the following commands
composer require dvaknheo/duckadmin
composer exec duckphp new
php cli.php require DuckAdmin/System/DuckAdminApp
# press Enter all the way
#php cli.php require DuckUser/System/DuckUserApp
#php cli.php require SimpleBlog/System/SimpleBlogApp
#php cli.php require DuckUserMangager/System/DuckUserMangagerApp
php cli.php run
Visit http://127.0.0.1:8080/duck-admin/ to open the admin backend.
Mode two: embedding into a standalone project
Take this route when you want to use it in an existing php-fpm project that has no admin backend.
A complete runnable example is in the section "How code from other non-DuckPhp frameworks integrates DuckAdmin's admin backend system" below
(it used to live in public/main.php, and that file has been deleted).
composer require dvaknheo/duckadmin
The core feature code is as follows
<?php
//We demonstrate embedding duckadmin in another framework system
$options=[
'path' => __DIR__.'/../', // specify the path
'skip_404' => true, // skip the internal 404 handling.
'app' => [
DuckAdminApp::class => [ // the admin backend system
'controller_url_prefix' => 'app/admin/', // the access path
'installed' => '2024-06-10T22:12:01+08:00', // the installed flag
'database_list' => //specify the database
[
'dsn' => 'sqlite:demodb.db',
'username' => '',
'password' => '',
],
],
],
];
$flag = DuckPhp::RunQuickly($options);
if ($flag) {
return; // the admin backend system is done
}
//////////////////////////
// you code here.
$url_admin = __url('app/admin/index'); // the admin address, the same as the controller_url_prefix you set
$admin_id = Helper::AdminId(false); // the admin ID
$admin_name = $admin_id ? Helper::AdminName() : ''; // the admin name
$url_logout = Helper::Admin()->urlForLogout();
As you can see, there is an extra skip_404 option. When a 404 is returned, handle your other business yourself The admin_id you get here is the id you get after logging in.
Mode three: manually modifying a duckphp project to embed it
Zero code is comfortable, but it is not the final solution
Once you are familiar with the duckphp project structure.
Modify the relevant config of your System/App.php in your duckphp project
public $options = [ //... 'app' => [ \DuckAdmin\System\DuckAdminApp::class => [ // the admin backend system 'controller_url_prefix' => 'app/admin/', // the access path // 'database_driver' =>'mysql', // if you want to switch to the mysql driver //... other config ], ], ];
Run
php cli.php run
Visit http://127.0.0.1:8080/app/admin/index to open the admin backend.
This is only the DuckAdmin backend; it does not include the user system, user management or blog example from the demo
Secondary development
Self-checks after an upstream upgrade (important)
DuckPhp's upstream interface changed signatures (for example canAccess() now takes $url as the second parameter),
and quite a few classes in this project do not write implements (they rely on the local_service / login_service /
login_session bindings in the App options to become upstream services), so PHP will not check it for us — such problems only blow up when execution reaches that line.
After an upstream update, run these two scripts:
php .ai/audit_signatures.php # contract signature audit: compare this project's implementations with the upstream interfaces parameter by parameter
php .ai/audit_upstream_refs.php # upstream reference audit: do the classes/methods referenced in src still exist
Both report their result through the exit code (0 pass / 1 problem), so you can hang them directly on CI or a git hook.
- The role table in
audit_signatures.php(which class takes on which contract) needs to be maintained in sync when you add a sub-application or change a binding. - "Loosening" a parameter type (
?stringagainst the interface'sstring) is allowed by LSP and reports a warn rather than a fail. - The coverage DSL (
CALL Class@method k=v) passes parameters by parameter name, so parameter names must match the contract too.
Prerequisites
Some basic knowledge of the duckphp framework is not repeated here
Adjusting options
DuckAdmin supports two database modes, sqlite / mysql; you can switch database_driver in the options
In a few versions we hope to be able to switch it in the installer (for now it can only be switched manually
Static resources
In the native era, we just unzipped the files into the web directory.
But we want to separate dynamic and static resources. To ease the load on the server.
So we have the controller_resource_prefix option.
So where are the resource files? Under the src/DuckAdmin/res directory.
You can set this option to a full http path, instead of using the default
You can also set it to an absolute path starting with /. The default is 'res/', and the installer will copy them to the corresponding directory.
Key points for installing the resource files
Because it involves installation, we set controller_resource_prefix, the necessity of cloning the files
Taking over views
The demo/view/DuckAdmin directory is the demo project's adjusted views
Using the API
In your project
Use DuckPhp\Foundation\Helper::Admin() to get the Admin object,
AdminId($check_login) , AdminName($check_login) get the current Id and the admin name
See the DuckPhp documentation for details, the class introduction of DuckPhp\GlobalAdmin\GlobalAdmin.
Use DuckPhp\Foundation\Helper::User() to get the generic User object,
See the DuckPhp documentation for details, the class introduction of DuckPhp\GlobalUser\GlobalUser.
Modifying the existing implementation
Basic knowledge of the duckphp framework is not explained again.
Advanced
Menus and permissions. How to add a menu in my controller. See the implementation of the user management system in the demo.
Coverage testing
Depends on xdebug's coverage mode (must be on for both CLI and FPM): xdebug.mode=develop,coverage.
The switch and the site base address are in config/DuckPhpSettings.config.php.sample.
Archiving by provider group (only one admin provider + one user provider can be in effect at a time, so one provider is one group):
# Recommended: --flag mode. The provider is chosen by a collection flag (request header), no disk config is touched, no race conditions bash .ai/cover_flags.sh # run all groups and merge the reports bash .ai/cover_flags.sh e_user f_blog # run only the specified groups REPORT_ARGS="--jsonl=runtime/DuckCoverage/report.jsonl --jsonl-no-timestamp" \ bash .ai/cover_flags.sh # also produce a machine-readable report.jsonl # Old mode (kept for rollback/comparison): each group chooses its provider by "POSTing to the home page to rewrite this group's config" bash .ai/cover_groups.sh
The reports are in runtime/DuckCoverage/AAAAA.report/ (multiple groups merged) or <group name>.report/ (single group);
next to each report there is a report.json from the same source.
--flag mode is cover --watch <group name> --flag=<flag> → cover --play --flag=<flag> → cover --report group....
The flag value is an application's controller_url_prefix without the trailing slash:
admin / admin_laravel / admin_thinkphp / single_admin / demo_users / user / blog.
System\TestLister matches the flag against options['app'][*]['controller_url_prefix'] and plays exactly that
application's child list; for user / blog it first calls toDefaultAdmin() / toDefaultUser(), which write the
dynamic provider config (saveProviders()) — the same path the admin homepage form uses. No flag, or a flag that
matches nothing, falls back to the full list (all seven applications in one play).
Note: the old "the flag only takes effect when duckcoverage_enable is true" lock disappeared together with
applyProviderFlag(); the flag no longer influences authentication, it only selects which list to play.
Do not use cover --go, its behavior differs from doing the steps manually.
Testing one child application on its own
--flag takes an application's controller_url_prefix (without the trailing slash), and
System\TestLister then plays only that application's child list:
bash .ai/cover_one.sh blog # only SimpleBlog (group name defaults to one_blog) bash .ai/cover_one.sh user e_solo # only User, with a custom group name
Equivalent to the steps by hand:
php bin/cli.php cover --watch <group> --flag=<prefix> php bin/cli.php cover --play --flag=<prefix> php bin/cli.php cover --stop php bin/cli.php cover --report <group>
The prefixes are admin / admin_laravel / admin_thinkphp / single_admin / demo_users / user / blog.
The play log shows what actually ran: every #INCLUDE_CHILD expands into a COMMENT APP <class> line,
so a single-application run has exactly one.
Two things bite: do not put a trailing slash on the flag (--flag=blog/ matches nothing and silently
falls back to the full list), and only the user / blog flags write the provider config for you
(toDefaultAdmin() = SingleAdmin, toDefaultUser() = DemoUsers) — the three admin* flags do not, so
switch the admin provider to that application in the home page form first; otherwise Admin::_() points
at another system and the numbers mislead you.
There is only one way to count lines, and "signature lines" have been removed upstream
runtime/DuckCoverage/*.report/index.html, thereport.jsonin the same directory, and thereport.jsonlproduced by--jsonl=are the same data (after this upstream patch all three agree).php .ai/coverage_gaps.php— readsreport.jsonland lists "lines still worth covering", sorted bytodo(=unc - sig) by default; it also lists separately the files that are "already at 100%, but had signature lines removed".
Signature lines (a historical problem, now solved upstream): when a method declaration's parameters have default values (especially
array defaults, for example public function log($user_id, string $string, ?string $type = null, array $ext = [])),
that declaration line is counted as an executable line by static analysis, but the coverage driver will never record it as executed —
src/DemoUsers/Business/UserBusiness.php therefore long showed a fixed 29/30.
Upstream has now removed such lines from the counting (an unset in the data layer, consistent across
HTML / report.json / report.jsonl), so:
- that file is now 29/29 = 100%, and
uncis empty; - the removed lines leave a trace in the
sigfield (sigon thereport.jsonside, alsosigon thejsonlside); - to decide "is there anything left to cover", just look at whether
todo(orunc) is empty, no need to subtractsigany more.
When an older
report.jsonlhas notodofield,.ai/coverage_gaps.phpautomatically degrades tounc - sig, so the tool works with both generations of output.
Ways to add coverage
-
Add
WEBsteps for anything expressible over HTTP. Write uris "relative to the application" (the framework addscontroller_url_prefix), and use absolute uris only across applications. -
For things HTTP cannot express (pure business/model methods, exception branches) use macros, which expand into
CALL <phase>!<namespace>\<layer>\<class>@<method> parameters:#BUSINESS <class>@<method> parameters→Business\<class>of the current application#MODEL <class>@<method> parameters→Model\<class>#ACTION <class>@<method> parameters→Controller\<class>#INCLUDE_CALL <call>→ expands directly intoCALL
⚠️ A
CALLthat throws an exception aborts the whole play: to hit error branches useWEB(an HTTP request will not abort the play), or wrap each step in its own try likemoreTest2()insrc/DemoUsers/System/TestLister.php.
These numbers are stale. They were measured before the test-list refactor (seven applications,
PHASElines written by the listers themselves,--flag= an application prefix), and the group table in.ai/cover_flags.shstill carries the old short-name flags — a full run today therefore plays the full list for every group. Re-measure before quoting them.
Current progress (6 groups, 532 HTTP requests, 0 application error logs): Total 77.92% (lines 2819/3618, functions 249/380 = 65.53%) — Admin 77.88%, Admin_LaravelModel 63.38%, Admin_ThinkPHPModel 63.78%, DemoUsers 100.00%, SimpleBlog 98.33%, SingleAdmin 100.00%, System 30.20%, User 90.77%.
How to read these percentages (all three movements have a reason — do not misread them):
- User 45.03% → 90.77%: the User application got its own test list;
- SimpleBlog 90.20% → 98.33%: fixed the real culprit — when the group name could not be resolved the
lists built
runtime/DuckCoverage/.db, so "install" went into one database while "register/login" went into another and the whole user side looked uncovered (that resolver,src/System/CoverageGroup.php, has since been deleted: upstream now guards the group name, and the DB name moved intoDuckAdminApp::getTestDbFile()); - System 65.15% → 30.20%: this module is only 3 files;
DuckAdminApp.phpgained the--flagprovider-switching logic and the coverage-only helper only runs on the CLI side (the web collector never sees it), so the numerator barely moved while the denominator grew — this is not a code regression.
Commands: bash .ai/cover_flags.sh (run every group and merge the reports), php .ai/coverage_gaps.php (list the lines still worth covering).
Multilingual (installer wizard and other texts)
The texts go through DuckPhp's Lang component, and each sub-application's language files live in its own config/:
src/Admin/config/lang-zh_CN.php # Admin (shared by Admin_LaravelModel / Admin_ThinkPHPModel)
src/Admin/config/lang-en_US.php
src/User/config/lang-zh_CN.php src/User/config/lang-en_US.php
src/SimpleBlog/config/lang-zh_CN.php src/SimpleBlog/config/lang-en_US.php
- The default language is the root application
DuckAdminApp'slang_default(en-US); sub-applications' Lang follows the root application throughlang_follow_root, so you only set it in one place, the root application. - How to switch:
?lang=en-US, thelangcookie, or the browser'sAccept-Language.- The file-name shape matters: browsers send
en-US(hyphen); the framework normalises it toen_USand then looks forlang-en_US.php. There is no "strip the region and retry" fallback —en-GBwill not hiten_US, and a bareenonly matcheslang-en.php.
- The file-name shape matters: browsers send
- The main language file wins over frag (
Lang's comment: the main language file always wins, a frag only fills in the missing keys) — to change the install page texts, changelang-*.php, do not create a separatelang-*-for_webinstaller.php, or your change will not take effect. - Keys are flat (a dot is just part of the name); a missing key falls back to the framework's built-in English and is not shown as a bare key.
- The installer's keys are
webinstaller.*; Admin additionally has the custom fieldsAdmin Name/Admin Password/Admin Password Confirm.
How code from other non-DuckPhp frameworks integrates DuckAdmin's admin backend system
DuckAdmin is a library and can be embedded into any framework (Laravel / ThinkPHP / an in-house framework all work): call
RunQuickly() once; it returns true to mean this request has been fully handled by the admin backend, so just return and hand
control back; if it returns false, keep running your own code.
(This code used to be public/main.php as a runnable demonstration; that file has been deleted, and the example is kept here.)
<?php use DuckPhp\DuckPhp; use DuckAdmin\System\DuckAdminApp; use DuckPhp\Foundation\Helper; $options = [ 'path' => __DIR__ . '/../', // DuckAdmin's root directory 'skip_404' => true, // routes not caught are handed back to your own framework 'app' => [ DuckAdminApp::class => [ 'controller_url_prefix' => 'app/admin/', // the backend's access path 'installed' => '2024-06-10T22:12:01+08:00', // the installed flag 'database_list' => [ 'dsn' => 'sqlite:demodb.db', 'username' => '', 'password' => '', ], ], ], ]; $flag = DuckPhp::RunQuickly($options); if ($flag) { return; // the admin backend is done, this request ends here } ////////////////////////// // your own code starts here $url_admin = __url('app/admin/index'); // the admin address (corresponding to controller_url_prefix above) $admin_id = Helper::AdminId(false); // the admin ID; false when not logged in $admin_name = $admin_id ? Helper::AdminName() : ''; $url_logout = Helper::Admin()->urlForLogout(); // ... render your own page, the variables above can be used directly in the view
A few notes:
- autoload: when installed as a library, what you
requireis your project'svendor/autoload.php. The originalmain.phpalso had a fallback that "searches up four levels if not found"; with a normal composer install you do not need it. path: points at DuckAdmin's root directory — it uses that to findconfig/,view/,runtime/.skip_404 => true: routes the backend did not catch are given back to you, so it does not take over the whole site's 404s.installed+database_list: make the library's application run "as if it were installed"; in a real project fill in your own database, or you can keep onlycontroller_url_prefixand let it go through the Web installer once (see "Demo" above).- Login state:
Helper::AdminId()/AdminName()/Helper::Admin()->urlForLogout()are for your pages; for view overrides and language overrides see the "Taking over views" and "Multilingual" sections above. - Security gate: when embedded in another framework it is still bound by
duckadmin_enable— if it is not on, only the notice page is output (see the "Security" section at the beginning).
Login event management
TBD
Repository layout
.
├── public/ The only entry point
│ └── index.php Boots the root application
├── src/ Code: one root application + 7 child applications
│ ├── System/ Root app: DuckAdminApp, main controller, root TestLister, WebInstaller
│ ├── Admin/ Admin backend (database-backed; the default admin provider)
│ ├── Admin_LaravelModel/ Same, but the Model layer is overridden with Eloquent
│ ├── Admin_ThinkPHPModel/ Same, but the Model layer is overridden with ThinkORM
│ ├── SingleAdmin/ Single-admin provider (no database)
│ ├── DemoUsers/ Demo user provider (preset accounts, no database)
│ ├── User/ User app: front-end register/login + user management
│ └── SimpleBlog/ Sample business app (blog) showing how to build on the baseline
├── view/ Views of the root application (home page: pick the providers)
│ └── main.php
├── config/ Per-environment config + root-app sentences
│ ├── DuckPhpSettings.config.php.sample Copy to .config.php and edit per environment
│ ├── lang-zh_CN.php
│ └── lang-en_US.php
├── bin/ Command-line entry (only one)
│ └── cli.php Command entry (run / call / cover ... provided by duckphp)
├── .ai/ Scripts used by AI tooling (not part of the app runtime)
│ ├── cover_flags.sh Coverage runs: --flag mode (recommended)
│ ├── cover_groups.sh Coverage runs: legacy mode (kept for rollback/comparison)
│ ├── cover_one.sh Coverage runs: a single child application (flag = its url prefix)
│ ├── coverage_gaps.php Reads report.jsonl and lists lines still worth covering
│ ├── provider_probe.php Shows which pair of providers a coverage flag resolves to
│ ├── audit_signatures.php Contract-signature audit (parameter-by-parameter vs upstream)
│ └── audit_upstream_refs.php Upstream-reference audit (do referenced classes/methods still exist)
├── runtime/ Runtime data: databases, coverage dumps and reports (not in git)
├── database/ sqlite and other data files
├── sync_to_wsl.sh Syncs the code to the WSL test machine (excludes runtime/vendor/env config)
└── README.md / README.zh_CN.md
Every child application has the same one-way layering (System → Controller → Business → Model):
src/<app>/
├── System/ Application entry class + that app's TestLister (coverage test list)
├── Controller/ Controllers
├── Business/ Business layer
├── Model/ Data layer
├── view/ Views
└── config/ Language packs and other config for that app
There is exactly one entry point: public/index.php boots the root application
src/System/DuckAdminApp.php, which mounts the 7 child applications under their own
controller_url_prefix (/admin/, /user/, /blog/, ...). So "adding a child application"
means dropping a directory into src/ and adding one line to the root app's app option —
not creating a new entry point.
TODO
Missing a validor validation system; using thinkphp's should have worked, but it is too complex, and later versions became more specialized
Missing a form generator. This is actually not important.