neophp / admin-package
Admin panel skeleton for NeoPHP: login, sidebar, dashboard, fully overridable by the host project
Requires
- php: >=8.5
README
A self-contained admin panel skeleton for NeoPHP: its own login system,
sidebar, and dashboard — fully overridable by the host project. The
package owns the layout (sidebar, topbar) and authentication; the project
owns everything inside {% block content %} for every page it adds to
the sidebar.
Structure
admin-package/
├── composer.json
├── README.md
├── src/
│ ├── NeoAdminPackage.php
│ ├── Controllers/
│ │ ├── AuthController.php
│ │ └── DashboardController.php
│ ├── Middleware/
│ │ └── AdminAuthMiddleware.php
│ ├── Service/
│ │ ├── AdminAuthManager.php
│ │ └── SidebarResolver.php
│ ├── Extension/
│ │ ├── AdminControllerExtension.php # $this->adminConfig() / $this->adminSidebar() in PHP
│ │ └── AdminViewExtension.php # adminConfig() / adminSidebar() in Twig
│ ├── Profiler/
│ │ └── AdminAuthCollector.php # "Admin" tab in the dev toolbar
│ ├── Assets/
│ │ ├── css/
│ │ │ ├── base.css # reset, body, main layout skeleton
│ │ │ ├── sidebar.css # sidebar, groups, dropdowns
│ │ │ ├── header.css # topbar
│ │ │ └── content.css # content area, login form
│ │ └── js/
│ │ └── admin.js # sidebar group toggle
│ └── Templates/
│ ├── layouts/
│ │ └── admin_layout.html.twig
│ ├── partials/
│ │ ├── sidebar.html.twig
│ │ └── macros/
│ │ └── Icon.macro.html.twig
│ └── pages/
│ ├── login.html.twig
│ └── dashboard.html.twig
├── config/
│ └── admin-system.config.php
└── database/
├── Entity/
│ ├── AdminUser.php
│ └── AdminRole.php
├── Repository/
│ └── AdminUserRepository.php
└── Migrations/
├── MigrationVersion_NeoAdmin_1.php # neo_admin_roles table
└── MigrationVersion_NeoAdmin_2.php # neo_admin_users table
The package's PHP namespace is Vendor\NeoPHP\AdminPackage, and its Twig
namespace (used for @NeoAdmin/... template paths) is NeoAdmin — two
different things, both referenced throughout this document.
This package has its own authentication system
Unlike a typical NeoPHP feature, this package does not rely on the
host project's Config/auth.config.php / AuthManager. It ships its own
AdminAuthManager, its own session key, and its own database tables:
neo_admin_roles— a simple role table, seeded withROLE_ADMINby its migrationneo_admin_users— email, hashed password, name,role_id
This is intentional: a project may already have its own user system for regular site visitors (customers, members, etc.), completely separate from who is allowed into the admin panel. There is no shared table, no shared session, and no dependency on the project's auth configuration.
If you want the admin panel to authenticate against your project's
existing users instead, this package is not built for that — you would
need to fork or extend AdminAuthManager yourself.
Installation
php bin/neo package:require neophp/admin-package --project=MyProject
Then register the package in the project's Config/app.config.php:
return [ // ... 'packages' => [ \Vendor\NeoPHP\AdminPackage\NeoAdminPackage::class, ], ];
Run the package's migrations to create its tables:
php bin/neo database:migration:migrate --project=MyProject
This creates neo_admin_roles (with ROLE_ADMIN seeded) and
neo_admin_users (empty — see below to create your first admin).
Creating your first admin user
There is currently no CLI command for this — insert directly:
<?php require "vendor/autoload.php"; $pm = new Neo\Core\Security\Auth\PasswordManager(); echo $pm->hash("your-password-here");
INSERT INTO neo_admin_users (email, password, name, role_id, created_at) VALUES ('admin@example.com', '<hash from above>', 'Admin', 1, NOW());
role_id = 1 corresponds to ROLE_ADMIN, seeded by the first migration.
Configuration
config/admin-system.config.php is copied once to
Config/Packages/NeoAdmin/admin-system.config.php in the target project:
<?php declare(strict_types=1); return [ 'title' => 'My Project Admin', 'description' => '', 'route_prefix' => '/admin', 'auth' => [ 'required_role' => 'ROLE_ADMIN', 'redirect_after_login' => 'admin.panel.index', ], 'sidebar' => [ 'dashboard' => [ 'controller' => \Vendor\NeoPHP\AdminPackage\Controllers\DashboardController::class, 'icon' => 'layout-dashboard', 'title' => 'Dashboard', ], 'users' => [ 'controller' => \Neo\Src\MyProject\App\Controllers\NeoAdmin\UsersController::class, 'icon' => 'users', 'title' => 'Users', ], 'Settings' => [ 'general' => [ 'controller' => \Neo\Src\MyProject\App\Controllers\NeoAdmin\SettingsGeneralController::class, 'icon' => 'settings', 'title' => 'General', ], 'theme' => [ 'controller' => \Neo\Src\MyProject\App\Controllers\NeoAdmin\SettingsThemeController::class, 'icon' => 'settings', 'title' => 'Theme', ], ], ], ];
Sidebar entries: links vs. groups
Each top-level key in sidebar is either a link or a group,
determined automatically by SidebarResolver:
- Link — has a
controllerkey. Rendered as a single clickable item. - Group — has no
controllerkey at its own level, only nested entries (each of which must itself have acontroller). Rendered as a collapsible dropdown containing its children.
'Settings' => [ // group: no 'controller' key here 'general' => [ 'controller' => ..., 'icon' => ..., 'title' => ... ], 'theme' => [ 'controller' => ..., 'icon' => ..., 'title' => ... ], ],
A child entry with 'controller' => null is not silently skipped —
it throws a clear error (does not reference a valid controller class)
so a misconfigured group fails loudly rather than rendering an empty
dropdown.
Every link-type entry (top-level or nested inside a group) must reference a controller that:
- Declares a
#[MainRoute(...)]attribute on the class - Has a public method named exactly
index, carrying a#[Route(name: 'index', ...)]attribute
The link's URL is resolved automatically from these two attributes
({mainRoute.name}.index) — there is no separate route field to keep in
sync manually.
auth.redirect_after_login
The route name to redirect to after a successful login. Defaults to the
package's own admin.panel.index. Change this if you override the
default dashboard with your own controller under a different route name
(see below).
Important: 'requiredRole' passed to #[Middleware(..., params: [...])]
on each of your own controllers is a static value — it is not re-read
from admin-system.config.php at runtime. If you change auth.required_role
in the config, update the params on every protected controller
(including any dashboard you write yourself) to match.
Adding your own admin pages
Create a controller anywhere in your project (conventionally under
App/Controllers/NeoAdmin/), following the index() convention, and
protect it with the package's middleware:
<?php declare(strict_types=1); namespace Neo\Src\MyProject\App\Controllers\NeoAdmin; use Neo\Core\Controller\AbstractController; use Neo\Core\Http\Response\Types\Response; use Neo\Core\Routing\Attribute\MainRoute; use Neo\Core\Routing\Attribute\Route; use Neo\Core\Security\Middleware\Attribute\Middleware; use Vendor\NeoPHP\AdminPackage\Middleware\AdminAuthMiddleware; #[MainRoute(path: '/admin/users', name: 'admin.users')] #[Middleware( use: AdminAuthMiddleware::class, onError: 'block', params: ['requiredRole' => 'ROLE_ADMIN'], )] final class UsersController extends AbstractController { #[Route(path: '/', name: 'index', methods: ['GET'])] public function index(): Response { return $this->render('pages/admin/users.html.twig'); } }
Then extend the package's layout in your view — only write what goes
inside the content area, the sidebar and topbar are never duplicated.
Note the Twig namespace @NeoAdmin, distinct from the package's PHP
namespace:
{# src/MyProject/Templates/pages/admin/users.html.twig #} {% extends '@NeoAdmin/layouts/admin_layout.html.twig' %} {% block content %} <h1>Users</h1> {# your content here #} {% endblock %}
Register the controller in admin-system.config.php's sidebar array
(as a top-level link, or nested inside a group) to have it appear in the
menu automatically. Clear the router cache after adding a new controller
in dev mode if it doesn't show up immediately.
Adding your own CSS or JS to an admin page
admin_layout.html.twig exposes two extra blocks, on top of content,
that your own pages can fill without ever touching the package:
{# src/MyProject/Templates/pages/admin/users.html.twig #} {% extends '@NeoAdmin/layouts/admin_layout.html.twig' %} {% block stylesheets %} <link rel="stylesheet" href="{{ asset('css/admin-custom.css') }}"> {% endblock %} {% block content %} <h1>Users</h1> {% endblock %} {% block javascripts %} <script src="{{ asset('js/admin-custom.js') }}"></script> {% endblock %}
Use your project's own asset() pipeline for this CSS/JS (compiled and
versioned the normal way) — this is different from how the package serves
its own assets (see below), and is the right place for anything
specific to a page you added yourself.
Overriding the default dashboard
The package's own DashboardController renders
@NeoAdmin/pages/dashboard.html.twig at /admin/panel by default.
To replace it, create your own controller — using a different URL path
than /admin/panel, since NeoPHP's router does not allow two controllers
to register the same [method, path] pair (it will throw a Route conflict error in dev mode):
#[MainRoute(path: '/admin/dashboard', name: 'admin.dashboard.custom')] #[Middleware( use: AdminAuthMiddleware::class, onError: 'block', params: ['requiredRole' => 'ROLE_ADMIN'], )] final class DashboardController extends AbstractController { #[Route(path: '/', name: 'index', methods: ['GET'])] public function index(): Response { return $this->render('pages/admin/dashboard.html.twig'); } }
Then update both the sidebar entry and the post-login redirect:
'auth' => [ 'redirect_after_login' => 'admin.dashboard.custom.index', ], 'sidebar' => [ 'dashboard' => [ 'controller' => \Neo\Src\MyProject\App\Controllers\NeoAdmin\DashboardController::class, 'icon' => 'layout-dashboard', 'title' => 'Dashboard', ], ],
Known limitation: the package's own /admin/panel route remains
registered and reachable even after you stop referencing it from the
sidebar — the router scans every controller the package ships,
regardless of whether your config points elsewhere. It stays behind the
same AdminAuthMiddleware, so this is not a security gap, just an
orphaned route serving the package's generic dashboard view if visited
directly.
Available template helpers
Two Twig functions are available in every template, via AdminViewExtension:
| Function | Returns |
|---|---|
adminConfig() |
The full contents of admin-system.config.php |
adminSidebar() |
Resolved sidebar entries — see Sidebar entries: links vs. groups for the shape of each item |
The same two methods are also available in PHP inside any controller, via
AdminControllerExtension:
$this->adminConfig(); $this->adminSidebar();
Icons
Sidebar and layout icons are inline SVGs (Lucide-style paths embedded directly in the package — no external font or CDN dependency), rendered via a macro:
{% import '@NeoAdmin/partials/macros/Icon.macro.html.twig' as Icon %}
{{ Icon.render('users', 18) }}
Currently available names: layout-dashboard, users, settings,
log-out, external-link, chevron-down. Unknown names fall back to a
plain circle. Add more by extending Icon.macro.html.twig directly.
Serving package assets
The package's own CSS and JS are served through NeoPHP's generic package asset route, not copied into the project:
/packages-assets/NeoAdmin/css/base.css
/packages-assets/NeoAdmin/css/sidebar.css
/packages-assets/NeoAdmin/css/header.css
/packages-assets/NeoAdmin/css/content.css
/packages-assets/NeoAdmin/js/admin.js
The stylesheet is intentionally split into four files instead of one, so
that a heavily customized project (or a future version of this README)
can reference only what it actually needs — for example, login.html.twig
only loads base.css and content.css, since the login page has no
sidebar or topbar.
Already wired into admin_layout.html.twig and login.html.twig — no
action needed. admin.js currently only handles toggling collapsible
sidebar groups open/closed.
Dev toolbar integration
If NeoPHP's profiler is enabled (environment: dev), an Admin tab
appears in the dev toolbar showing whether an admin session is currently
active, and which email/role is logged in. This reflects
AdminAuthManager's own session — it is intentionally separate from
whatever the toolbar shows for the project's own AuthManager, since the
two systems don't share state.
Middleware
AdminAuthMiddleware checks, in order:
AdminAuthManager::check()— is an admin session active at allAdminAuthManager::hasRole($requiredRole)— exact role match (no role hierarchy — this package does not depend on any role-hierarchy add-on)
Apply it to any controller you want protected under /admin, passing the
required role via the #[Middleware] attribute's params.
Migrations
Both migrations are run automatically alongside the project's own via
database:migration:migrate, following NeoPHP's package migration
convention (MigrationVersion_NeoAdmin_{n}). They are never copied into
the project — read directly from the package.
License
MIT