enicore / rivena
A PHP framework for admin-focused websites.
Requires
- php: ^8.4
- ext-fileinfo: *
- ext-gd: *
- ext-intl: *
- ext-mbstring: *
- ext-openssl: *
- ext-pdo: *
- ext-pdo_mysql: *
- ext-zlib: *
- bacon/bacon-qr-code: ^3.1
- bjeavons/zxcvbn-php: ^1.4
- donatj/phpuseragentparser: ^1.12
- enshrined/svg-sanitize: ^1.0
- illuminate/database: ^13.0
- matthiasmullie/minify: ^1.3
- maxmind-db/reader: ^1.12
- php-di/php-di: ^7.1
- phpmailer/phpmailer: ^7.1
- psr/container: ^2.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-middleware: ^1.0
- report-uri/passkeys-php: ^2.0
- slim/psr7: ^1.8
- slim/slim: ^4.15
Requires (Dev)
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A PHP framework for building admin-focused websites. It uses Slim, Eloquent and Alpine.js, with users, logins and an
admin area built in. Needs PHP 8.4 and, for development, sass on the web server.
New project
composer require enicore/rivena vendor/bin/rivena init # files, composer.json, database, site address, first admin, .env vendor/bin/rivena migrate # Rivena's new migrations, then the app's (sql/migrations in both) vendor/bin/rivena build # builds the styles and scripts (dev does it on each request)
Point the web server at public/ and open /usr. Init asks for the site URL and the database first and fails without
them, before creating anything (an existing .env gets what it can). It never replaces existing files; run it again to
add what's missing.
.env dev/prod differences: database, SMTP, site_url, app_env, app_key, admins (not in git)
public/index.php the routes
src/config.php the rest of the configuration
src/Controllers/ the controllers (HomeController: the home page)
src/views/ the views; one named like Rivena's replaces it (layout.php: the public pages' layout,
admin/partials/sidebar.php: the menu)
src/styles/app.scss the styles -> public/css/app.css
src/scripts/ the scripts -> public/js/app.js (with Rivena's scripts and Alpine)
With app_env=dev: styles and scripts are rebuilt on the next request after a change (errors in the Error Log), pages
reload when src/ or public/ changes, error pages show the details, no error emails. The built files are committed.
The app's tables: sql/migrations/*.sql, each run once by rivena migrate in name order (recorded as app/<file>);
the first one creates them. Migrations only add (tables, columns with a default), so the running code keeps working:
migrate, then deploy; drop what's unused in a later release.
Routes
$app = new App(dirname(__DIR__)); $app->get('/', [HomeController::class, 'page']); $app->removeRoutes('system'); // a Rivena page out, with its actions and menu link // logged-in users $app->group(Config::get('user_path'), function (RouteCollectorProxy $group) { $group->get('', [DashboardController::class, 'page'])->setName('dashboard'); $group->get('/reports', [ReportsController::class, 'page'])->setName('reports'); })->add(CsrfMiddleware::class)->add(AuthMiddleware::class)->add(SessionMiddleware::class); // admins only $app->group(Config::get('user_path'), function (RouteCollectorProxy $group) { $group->get('/settings', [SettingsController::class, 'page'])->setName('settings'); })->add(AdminMiddleware::class)->add(CsrfMiddleware::class)->add(AuthMiddleware::class) ->add(SessionMiddleware::class); $app->run();
Rivena's routes are at user_path (/usr): login, logout, account, and for admins users, roles,
permissions, events, error-log, system; actions are named after their page (users.save). To replace one,
remove it and add yours with the same name. To restrict one, with its actions:
$app->getRouteGroup('account')->add(new PermissionMiddleware('account.edit'));
Options of new App(): services (PHP-DI definitions, e.g. AuthController::class => DI\autowire(MyAuth::class)),
config_path, env_path, csp.
Adding a page
// src/Controllers/ReportsController.php (namespace App\Controllers); Controller for pages outside the admin area class ReportsController extends AdminController { public const array PERMISSIONS = ['reports.view' => 'View reports']; // shown on the Permissions page public function page(Request $request, Response $response): Response { return $this->sendPage($request, $response, 'admin/reports', ['users' => User::count()], 'Reports'); } public function save(Request $request, Response $response): Response { try { $name = $request->getString('name', maxLength: 100, allowEmpty: false); $age = $request->getInt('age', default: 0); $active = $request->getBool('active', default: false); // unchecked checkboxes send nothing } catch (InputException $e) { return $response->sendError($e->getMessage(), 400); } if (...) { return $response->sendFieldErrors(['name' => 'Another report has this name.']); } $this->logEvent('report.saved', $name); return $response->sendSuccess(['id' => $id]); } }
<!-- src/views/admin/reports.php --> <div x-data="reportsController"><h4>Reports</h4>Users: <span x-text="data.users"></span></div>
// src/scripts/reports.js; the page's data is in this.data; class names must differ from Rivena's class ReportsController extends Controller { save() { this.submit(`${userPath}/reports/save`, {name: this.name}, data => this.showNotification('Saved.')); } } window.reportsController = () => new ReportsController();
Styles: src/styles/_reports.scss with @use "reports"; in app.scss. Menu link in
src/views/admin/partials/sidebar.php: <a href="<?= w_route('reports') ?>">.
sendPage()answers the whole page, or JSON when RivenaSpa loads it. AlsosendView()(no layout),sendErrorPage(),notFoundRoute().- Responses:
sendSuccess(),sendError(),sendFieldErrors()(shown at the fields bysubmit()),sendJson(),sendHtml(),sendText(),sendRedirect(). - JS
Controller:submit(url, data, done),post(url, data),confirm(options, action)(the confirmation dialog;password: trueasks for the password),showNotification(). - Views:
<?= $this->partial('admin/partials/pager') ?>,w_route('users'),w_csrf(),w_url()/w_script()/w_style()/w_image()(versioned asset URLs). Comments in views are PHP comments.
Public pages. A Controller (like HomeController), in the layout view: src/views/layout.php (from init), the
site's design. The login pages have their own layout (auth-layout, with noindex). Public pages have no session, so
visitors get no cookie. A form needs one: SessionMiddleware on its page and CsrfMiddleware too on its action, and
<meta name="csrf" content="<?= w_csrf() ?>"> in the layout (postJson() sends it). Auth::check() tells whether a
user is logged in (also with the remember cookie), then Auth::id(); it starts a session for every visitor of the page.
Tables. dataTable() in an admin controller returns a page of a query with the client's page, sort and search:
return $response->sendSuccess($this->dataTable(User::query(), $request->all(), sortable: ['email', 'created_at'], searchable: ['email'], map: fn(User $user) => ['id' => $user->public_id, 'email' => $user->email]));
In JS: this.table = new Table(${userPath}/users/list, this.data.table, {query: true}), with the
admin/partials/pager view. A sortable header:
<th :aria-sort="table.ariaSort('email')"> <button type="button" class="sortable" @click="table.sortBy('email')"> Email <span x-text="table.arrow('email')" aria-hidden="true"></span> </button> </th>
Users
- Admins are the user IDs in
admins(.env). They have every permission and can't be disabled or deleted. - Roles are managed on the Roles page; every user has the built-in User role.
User::can('reports.view')checks the logged-in user,PermissionMiddlewarea route,User::isAdmin()the admin status. Only users with theloginpermission can log in (the User role has it). password_resetpermission: the login page offers "Forgot password" (needs SMTP).- Two-factor authentication: users set it up on their account page (
two_factor_methods:totp,webauthn);two_factor_requiredmakes it mandatory. WebAuthn keys work only onsite_url's host. - Public IDs: models with
HasPublicId(likeUser) have a randompublic_idfor URLs and JSON;User::findByPublicId($id).
Configuration
src/config.php sets $config['key'] (Config includes it); .env overrides the values (taking their type; arrays
are comma-separated). Read with Config::get('key', $default).
- Required:
app_key,site_name,site_url(the https:// address: links in emails, WebAuthn),db_host,db_port,db_database,db_username,db_password app_env(dev),admins,user_path(/usr)- Mail:
smtp_host,smtp_port(587),smtp_username,smtp_password,smtp_timeout(10),mail_from error_notification_email(at most one email an hour),event_log_retention_months(6)password_min_length(12),password_max_length(60),password_min_score(3, zxcvbn)two_factor_methods(['totp', 'webauthn']),two_factor_required(false),two_factor_email_notifications(true)table_rows_per_page(25),image_max_megapixels(25)
RivenaCSS
@use "../../vendor/enicore/rivena/assets/scss/rivena" with ($icons: ('logo': '<svg ...>')); .box { width: 50%; @include rivena.size(xs) { width: 100%; } // xs, sm, md, lg, xl, sm-down, sm-up... } :root { --line-height: 1.6; } // the settings are CSS variables
$icons become icon-<name> classes; $font-path: false uses the system font. Hide by size with hide-xs,
hide-sm-down... The color classes are in rivena_colors.css. The frontend is also on npm (@enicore/rivena).
License
Enicore Rivena is licensed under the MIT License. See LICENSE for more information.