A content management system built on StarDust and Laravel 13, made to run on ordinary shared hosting. It needs no shell access, no daemons and no queue worker. Background work runs in-process on a scheduled tick, driven by cron where the host has it or by a secret URL where it doesn't.
Content starts in the model builder, a drag-and-drop admin page where you define a model and its fields. You can add, rename, retype, promote and demote fields, and before anything is saved you see what the save will do to existing entries. Seventeen field types ship with StarSystem (text, rich text, number, date, file, relation and more), and modules can add their own.
Each model then gets its entries, the content itself. Editors create and edit them in a form built from the model's fields, and the list pages through them newest first. Deleting an entry moves it to the model's trash, where it can be restored or deleted for good. Anything left in the trash for 30 days is deleted on its own, and STARSYSTEM_ENTRY_TRASH_DAYS changes that.
A site shows its entries to visitors through a theme, a folder in themes/ with Blade templates, a stylesheet and a theme.json. A plain default theme ships with StarSystem. On the Site page you pick the theme and say which models are public and at which address. A model at blog has its entries at /blog/their-address and can list them at /blog, and a model with no address sits at the root, so an About page is /about. An entry is public unless it is in the trash or, when you name a checkbox field as the published switch, that box is not ticked. Rich text is cleaned of scripts before a visitor gets it, and public pages start no session and set no cookie. A theme is PHP, so themes are copied into the folder over FTP rather than installed from a page, and STARSYSTEM_THEMES_PATH moves that folder. An update leaves the modules and themes you added where they are.
Access is set per site. Each site has its own roles, each made of abilities such as seeing the media library or deleting entries for good, and a person holds one role on each site they belong to. Administrator, Editor and Viewer come built in, and you can add your own. An ability can apply to every model or only to chosen ones, and editing and deleting entries can be limited to a person's own. Administrators invite people by email, pause or remove them, and can require two-factor sign-in for a role. An Activity page records sign-ins, role changes and invitations, and drops rows after 365 days, which STARSYSTEM_ACTIVITY_KEEP_DAYS changes.
Around them sit the other parts of the admin.
- The media library keeps each site's files in folders, with search and drag-and-drop upload. Other pages reuse its picker, the builder's file field among them.
- Modules are Composer-free plugins, installed by uploading a zip. A module registers its own hooks, migrations and admin pages, and can add abilities for roles to be given. If one fails to load, StarSystem switches it off and the site keeps running.
- The updater checks for a new release and applies it from the admin. It downloads, verifies, swaps the files and migrates, and rolls back by itself if a step fails partway.
- Multi-site is off by default, so every request resolves to one site. Set
STARSYSTEM_MULTISITE=trueand each site is matched by its own domain.
- PHP 8.3+
- MariaDB 10.11+ or MySQL 8.0+
- Node.js (for building the front end)
- Composer
composer setup # composer install, .env, APP_KEY, migrate, npm install, npm run build
composer dev # serves the app, queue listener and Vite dev server togetherThen open the app in your browser. With no .env yet, or one with no APP_KEY, every request redirects to /install. The installer walks through requirements, database details, migrations and the first administrator, still with no shell.
Mail is optional. Invitations, password-reset links and email changes use it when it's set up, and a host with no outgoing mail still works. Without it, the invitation dialog shows the link so you can pass it on yourself, and an email change takes effect at once instead of waiting for a confirmation link. StarSystem counts mail as set up once MAIL_MAILER is anything other than log or array. With mail set up, an account whose address was never confirmed has to open a link sent to it before it can use the admin, which only happens to an address changed while mail was off. Without mail nobody is asked to confirm.
Set these in .env to send through an SMTP server.
MAIL_MAILER=smtp
MAIL_SCHEME=smtps
MAIL_HOST=mail.example.com
MAIL_PORT=465
MAIL_USERNAME=hello@example.com
MAIL_PASSWORD=
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"MAIL_SCHEME=smtps goes with port 465, where the connection is encrypted from the first byte. A host on port 587 that upgrades the connection itself takes MAIL_SCHEME=null. Use the login address as MAIL_FROM_ADDRESS, because many hosts refuse mail sent from any other.
The suite needs a real MariaDB or MySQL server. phpunit.xml points at MariaDB on 127.0.0.1:33110 by default, and overriding the port runs the same suite against MySQL.
composer ci:check # lint check, types, front-end tests, the full suite (MariaDB)
php artisan test # just the suite
DB_PORT=33080 php artisan test # the same suite against MySQLcomposer lint formats PHP with Pint. npm run check formats and lints the front end, and npm run test runs its tests.
build/release.php packages one commit into a zip a shared-host owner can upload. The zip holds vendor/ installed with --no-dev and the built front end, and the script writes a release.json feed entry with the version, the zip's URL and its checksum. Pushing a v* tag that matches VERSION builds it and publishes it as a GitHub release through .github/workflows/release.yml.
The release.json is signed with an Ed25519 key, and an install refuses a feed that isn't, so someone who replaces the feed can't hand it a different zip. The public half of the key is FeedSignature::PUBLIC_KEY in app/Update/FeedSignature.php. The secret half is the repository secret STARSYSTEM_RELEASE_KEY, which the release workflow passes to build/release.php, and the build stops without it. The build keeps the secret from composer install, so no dependency's script can read it. --unsigned makes a local test build that no install will accept.
A signature shows who made a feed, not that it is current. Each install keeps the newest signed release it has seen in storage/app/update-feed-seen.json. When the feed later offers an older release, the Updates page keeps offering the newer one and warns that the feed looks out of date. For this to work, never delete a published release to withdraw it, because every install that saw it would then warn for good. Publish a newer release instead, and publish releases in version order. The release workflow leaves GitHub's default for the "latest" address, which weighs the version number as well as the date, so an older line's backport should not take over. If one is ever needed, add --latest=false to gh release create in release.yml first.
To make a new key pair, run this from the repository root. It prints the secret and then the public key.
php -r 'require "vendor/autoload.php"; $k = sodium_crypto_sign_keypair(); echo base64_encode(sodium_crypto_sign_secretkey($k)), PHP_EOL, base64_encode(sodium_crypto_sign_publickey($k)), PHP_EOL;'To change the key, publish a release signed with the old key that carries the new public key, then replace the secret. An install that updates to that release checks the next feed with the new key. A copy of StarSystem that publishes its own releases puts its own public key in FeedSignature::PUBLIC_KEY, so every release it ships already carries it. An install that follows some other feed sets STARSYSTEM_UPDATE_FEED and STARSYSTEM_UPDATE_KEY in its .env.