WordPress made one idea familiar to everybody who runs a website: the site does the basics, and plugins and themes add the rest, installed from a zip and switched on and off in the admin. Laravel developers usually get the opposite: a clean framework and a lot of freedom, but every feature a client asks for becomes a change to the application itself. PN Scripts Press is our open-source CMS that brings the WordPress model to Laravel: pages built from blocks, a blog, menus, media, SEO fields and revisions in a Filament admin, extended by plugins and themes that are folders with a manifest. This guide installs it, writes a small plugin and a theme, and explains where the model stops.
#Install
You need PHP 8.4, Composer, Node.js and npm. The package is on Packagist, so one command creates a new site, copies the example environment file, sets the application key, creates an SQLite database and runs the migrations:
composer create-project pnscripts/pn-press my-site
cd my-site
php artisan db:seed
npm ci
npm run build
php artisan serve
The seeder adds a demo site: a home page, About and Contact pages, two menus, the Folio theme and the two example plugins, Contact Form and SEO Sitemap. Skip it for an empty site. The front-end build is needed because the package does not ship compiled styles. Open http://127.0.0.1:8000 for the site and /admin for the admin; the seeded administrator account is listed in the README, and you should change its e-mail and password at once.
Before the site goes anywhere public, change two settings in .env. A new project is a development project: APP_ENV=local and APP_DEBUG=true show detailed errors and a debug toolbar on every page. Set APP_ENV=production and APP_DEBUG=false, and use MySQL or PostgreSQL if several editors will work at once.
#What a plugin is
A plugin is a folder in plugins/ with a plugin.json. The manifest names the plugin, its version, the version of Press it needs, a namespace that Press autoloads from the plugin's own folder (no Composer step), and a Laravel service provider where the plugin does its work. Optional parts are migrations, views, a route file and admin screens. A plugin goes through five states: installed (unpacked, nothing runs), active (its provider and migrations run), updated (a new version replaces the folder and new migrations run once), deactivated (it stops loading, its data stays) and uninstalled (its migrations are rolled back and its folder and settings are removed).
Plugins change the site through actions and filters, as in WordPress: add_action and do_action to run code at a point, add_filter and apply_filters to change a value on its way to the page. Press provides hooks for the page head and body, page blocks, post content, menus, page templates, the settings screen and the sitemap.
#Write a plugin: a list of sections
Long posts read better with a short list of their sections at the top. Press does not have one, so it makes a good first plugin. It needs two files. The manifest:
{
"slug": "contents",
"name": "Contents",
"version": "1.0.0",
"description": "A list of a post's sections above its text, with links to each heading.",
"author": "You",
"requires": { "press": ">=1.0" },
"provider": "Acme\\Contents\\ContentsServiceProvider",
"autoload": { "psr-4": { "Acme\\Contents\\": "src/" } }
}
And the service provider in src/ContentsServiceProvider.php:
<?php
namespace Acme\Contents;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
class ContentsServiceProvider extends ServiceProvider
{
public function boot(): void
{
add_filter('post.content', function (string $html): string {
$items = [];
$html = preg_replace_callback('#<h2>(.*?)</h2>#s', function (array $m) use (&$items): string {
$text = html_entity_decode(trim(strip_tags($m[1])), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$id = (Str::slug($text) ?: 'section').'-'.(count($items) + 1);
$items[] = '<li><a href="#'.e($id).'">'.e($text).'</a></li>';
return '<h2 id="'.e($id).'">'.$m[1].'</h2>';
}, $html);
// The post body is printed as HTML: escape everything you add to it.
if (count($items) < (int) press_setting('plugin.contents.min_sections', 3)) {
return $html;
}
return '<nav aria-label="Contents"><p class="press-eyebrow">Contents</p><ol>'
.implode('', $items).'</ol></nav>'.$html;
});
add_filter('settings.sections', function (array $sections): array {
$sections[] = Section::make('Contents')->schema([
TextInput::make('plugin.contents.min_sections')
->label('Show the list from this many sections')->numeric()->minValue(1)->default(3),
]);
return $sections;
});
}
}
The post.content filter receives the post's HTML. The plugin gives every second-level heading an id, collects the headings into a list with links, and puts the list above the text when there are at least three sections. The second filter adds a section to the admin's Settings screen, so an editor can change that number without touching code; press_setting() reads it. Two details are worth copying into every plugin. The post body is printed as HTML, so everything the plugin adds is escaped with e(). And entities such as & are decoded before escaping, or "Q&A" would appear with a visible &, which our first version did.
Zip the folder and install it from the command line in the site folder, or upload the zip under Extend, Plugins in the admin:
zip -r contents.zip contents
php artisan press:plugin install contents.zip
php artisan press:plugin activate contents
On a post with the headings "Как да планирате седмицата", "Q&A" and "Done", the plugin adds this list and these ids; Laravel turns the Cyrillic heading into a Latin id:
<nav aria-label="Contents"><p class="press-eyebrow">Contents</p><ol><li><a href="#kak-da-planirate-sedmicata-1">Как да планирате седмицата</a></li><li><a href="#qa-2">Q&A</a></li><li><a href="#done-3">Done</a></li></ol></nav>
<h2 id="kak-da-planirate-sedmicata-1">
<h2 id="qa-2">
<h2 id="done-3">
To change the plugin later, raise its version, zip it again and install it with --replace; it stays active, and if it has new migrations they run on the next request.
#More than filters
The same provider can register page blocks that editors add in the page editor, a route file for public pages (it is loaded with the site's routes, so php artisan route:cache works, and a deactivated plugin's routes answer 404 even from an old cache), and a Filament plugin class that adds admin screens. The Contact Form example shows all of it: a block, a route with a rate limit and a honeypot, its own table, a settings section and an inbox for messages with CSV export.
#A theme with settings
A theme is a folder in themes/ with a theme.json. Any Blade view in its views/ folder replaces the default view with the same path, and views you do not copy keep working. Settings declared in the manifest appear under Customize on the Themes screen, are checked against their type, and reach the page as CSS variables (a colour called accent becomes --press-accent) and attributes on the page. A minimal theme with an accent colour, a footer note and its own stylesheet:
{
"slug": "plain-ink",
"name": "Plain Ink",
"version": "1.0.0",
"description": "Black on white with one accent colour.",
"author": "You",
"requires": { "press": ">=1.0" },
"stylesheet": "assets/theme.css",
"settings": [
{ "key": "accent", "label": "Accent colour", "type": "color", "default": "#b4232a" },
{ "key": "footer_note", "label": "Footer note", "type": "text", "default": "Printed on the web." }
]
}
Copy resources/views/press/partials/footer.blade.php to the same path under the theme's views/ folder and change the copyright line to print the setting:
<p>© {{ now()->year }} {{ $siteName }}. {{ app(\App\Press\Themes\ThemeManager::class)->setting('footer_note') }}</p>
Install and activate it with php artisan press:theme, or upload it under Appearance, Themes, where Preview shows the site in the new theme to you alone before Activate switches it for everyone. One limitation matters here: a theme uploaded to a live site is not run through the front-end build, so its styles go in its own stylesheet, or it uses the classes the core already compiles.
#Security: a plugin is code
A plugin runs as PHP with full access to the site and its database, exactly like a WordPress plugin, so install only code you trust and keep the right to install plugins and themes for administrators. Press checks every upload before writing anything: archive size, number of files, unpacked size and compression ratio; no absolute paths, .., backslashes, symbolic links or hidden files such as .env or .htaccess; only allowed file types. A theme may contain PHP only as Blade views; when we added a plain .php file to a theme, the install was refused. On sites where nobody should upload code from the browser, set PRESS_PACKAGE_UPLOADS=false and install over SSH.
#Where the model stops
Press has no plugin or theme directory, no automatic updates and no remote installs, on purpose: code reaches the site only when somebody puts it there. It has no comments, no multilingual content and no multi-site support. Plugins are written in PHP against Laravel and Filament, so a plugin author needs to know both; WordPress plugins do not run in it.
#Where to get it
PN Scripts Press is free and open source under the MIT licence, on GitHub and Packagist. It is built with Laravel 13, Filament 5, Livewire 4, Spatie Permission and Tailwind CSS 4, and comes with the Folio and Nocturne themes, the two example plugins and guides for writing plugins and themes.
#Frequently asked questions
Can WordPress plugins run in PN Scripts Press?
No. The model is the same, folders with a manifest, actions and filters, activation in the admin, but plugins are written for Laravel and Filament.
How do I install a plugin without the admin?
Run php artisan press:plugin install with the zip or folder, then php artisan press:plugin activate with the plugin's slug.
Is it safe to upload plugins from the browser?
Uploads are checked for size, paths, hidden files and file types, but a plugin is still PHP with full access to the site. Install only code you trust, or turn uploads off with PRESS_PACKAGE_UPLOADS=false.
What do I need to change before going live?
The seeded administrator's e-mail and password, APP_ENV=production and APP_DEBUG=false in .env, and usually MySQL or PostgreSQL instead of SQLite.
Comments
Comments
Be the first to leave a comment.
Leave a comment