sureshmayanglambam / surexcore
SurexCore — a lightweight CMS for shared hosting on CodeIgniter 4 + Blade: content types with a field builder, media library, preview, backups and a web installer.
Package info
github.com/SureshMayanglambam/SurexCore
Type:project
pkg:composer/sureshmayanglambam/surexcore
Requires
- php: ^8.2
- codeigniter4/framework: ^4.7
- eftec/bladeone: ^4.19
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
SurexCore
A lightweight, secure CMS for shared hosting — CodeIgniter 4 + Blade. Build content types in the admin panel, write the website yourself in plain Blade.
Version 0.1.8 (developer preview) · Developed by Suresh Mayanglambam
This is a preview shared for feedback. See Feedback at the end.
Contents
- Requirements
- Get SurexCore
- Quick start (Docker)
- Quick start (MAMP / XAMPP / shared hosting)
- What's in the box
- Folder structure
- The sample: route → controller → model → view
- Generators
- Content types and custom fields
- Routing, views and Blade
- Frontend assets (Sass / JS)
- Forms and mail
- Admin panel features
- Configuration (.env)
- Database changes (automatic migrations)
- Deploying to shared hosting
- Security
- Feedback
Requirements
- PHP 8.2+ with
intl,mbstring,mysqli,curl,fileinfo,gd(andzipfor backups with uploads) - MySQL 5.7+ / 8 or MariaDB 10.3+
- Apache with
mod_rewrite(LiteSpeed works too)
Get SurexCore
Start a new project — one command
composer create-project sureshmayanglambam/surexcore my-site
That's it: my-site/ contains SurexCore with CodeIgniter 4 and BladeOne already in vendor/. Then:
cd my-site php spark serve # → http://localhost:8080 — the installer opens
The installer (in the browser) asks for the database, site name and the first 管理者 account, then creates .env with a new encryption key, builds all tables and logs you in.
No command line is needed on the server later — see Deploying.
Other ways
| Way | Command | vendor/ |
|---|---|---|
| Composer (recommended) | composer create-project sureshmayanglambam/surexcore my-site |
installed automatically |
| Release zip — no Composer at all | download SurexCore-x.y.z.zip from Releases and extract it |
included |
| Git clone — to work on SurexCore itself | git clone https://github.com/SureshMayanglambam/SurexCore.git my-site && cd my-site && composer install |
after composer install |
Without vendor/ the site can't start; SurexCore then shows a page explaining how to fix it.
Quick start (Docker)
composer create-project sureshmayanglambam/surexcore my-site
cd my-site
docker compose up -d
| URL | What |
|---|---|
| http://localhost:8095 | Site — the first visit opens the installer |
| http://localhost:8095/admin/login | Admin panel |
| http://localhost:8082 | phpMyAdmin |
| http://localhost:8026 | Mailpit (catches every mail sent locally) |
In the installer use: host db, port 3306, database surexcore, user surexcore, password surexcore_secret.
For local mail, set in .env afterwards: email.SMTPHost = 'mail', email.SMTPPort = 1025, email.SMTPCrypto = ''.
Run PHP commands inside the container:
docker compose exec app php spark routes docker compose exec app php spark migrate:status
The docker/ folder and docker-compose.yml are optional: they are only used by docker compose. On MAMP, XAMPP or a server they are ignored.
Quick start (MAMP / XAMPP / shared hosting)
- Create the project inside the web root (
cd /Applications/MAMP/htdocs && composer create-project sureshmayanglambam/surexcore my-site), or extract the release zip there. - Create an empty MySQL database.
- Open the site in the browser (e.g.
http://localhost:8888/surexcore/). The installer asks for the database, site name and the first admin account, then writes.env(with a new encryption key), creates all tables and logs you in.
The installer locks itself afterwards (writable/installed.lock). To reinstall from scratch, delete that file and .env and empty the database.
What's in the box
| Area | Features |
|---|---|
| Content | Content types with an ACF-style field builder (16 field types incl. relation, groups, repeaters, conditional show/required), a real table per type, SEO fields, drafts, publish period (start and optional end), preview of unsaved changes on the real page, duplicate, trash with restore, list columns and date filters |
| Code | Laravel-style models and queries (NewsModel::published()->latest()->paginate(10)), make:cms-model / make:cms-controller generators, a Page controller for view-only pages, a sample with every field kind |
| Media | Media library (grid, search, drag & drop upload, where-used, delete), "メディアから選択" in every image/file field, CKEditor 5 image upload |
| Site | Blade frontend you write yourself, contact form (入力 → 確認 → 完了) with file attachment and mail templates, automatic sitemap.xml / robots.txt, noindex switch, maintenance mode |
| Admin | English / Japanese UI (English by default, switch in the top bar), roles, users, branding (logo), activity log, dashboard with charts and disk usage, お問い合わせ inbox (optional), backup download (.sql or .sql + uploads .zip) |
| Ops | Installer, automatic migrations, hidden admin URL, one-click deploy packages in the admin (or deploy.sh) |
| Security | Locked-down web server rules, no code execution in uploads, strict headers + admin CSP, CSRF protection, secure cookies, sanitized editor HTML, metadata-free images — see Security |
Folder structure
App/
Admin/ Admin panel controllers (namespace App\Admin)
Controller/ SurexCore's controllers (Page, Api, Seo, Install, …) and the FrontController base
Http/ Your site's controllers: Home, News (sample), Contact ← you work here
Model/ SurexCore's models (EntryModel base, users, media, …)
Http/ Your content type models: NewsModel (sample), … ← and here
Commands/ spark commands: make:cms-model, make:cms-controller, db:export
Config/ CodeIgniter config + Cms.php (SurexCore settings), Sitemap.php
Database/Migrations/ Schema changes, applied automatically
Database/Seeds/ NewsSample (php spark db:seed NewsSample)
Entities/ Entry (one row of a content type table)
Filters/ InstallCheck, Maintenance, AdminAuth, DbUpgrade
Libraries/ Blade, Fields, ContentSchema, Installer, DatabaseExport, ...
routes/
web.php Public website routes ← and here
admin.php Admin routes
View/
frontend/ The website ← and here
layout/ default (HTML page + SEO/OGP meta), header, footer
index.blade.php Top page (the welcome page — replace it)
news/ SAMPLE list/detail pages
contact/ Contact form + mail/ templates
admin/ Admin panel (AdminLTE 4) and the installer
system/ 404, maintenance, CodeIgniter error pages, pager
public/
assets/frontend/ Website assets ← and here
scss/ → css/style.min.css js/script.js + js/module/ → js/script.min.js
css/welcome.css, news.css, contact.css (page styles) lib/ (jQuery, WOW)
assets/admin/ Admin panel CSS / JS
assets/vendor/ Bootstrap, AdminLTE, Bootstrap Icons, CKEditor 5, Chart.js, flatpickr (self-hosted)
uploads/ Uploaded images/files
writable/ Cache, logs, sessions, installed.lock, private uploads (contact attachments)
deploy.sh Builds upload packages for shared hosting
The sample: route → controller → model → view
The お知らせ (News) sample shows the whole pattern and every common field kind. Create it with:
php spark db:seed NewsSample
It creates the content type news — fields news_title (text), news_type (radio), news_category (checkbox), news_pickup (on/off), news_summary (textarea), news_image (image), news_detail (CKEditor), news_links (repeater) and news_related (relation) — plus 3 published entries. Running it again on an older sample adds the missing fields.
Route — routes/web.php
$routes->get('news', [News::class, 'index'], ['as' => 'news']); $routes->get('news/(:segment)', [News::class, 'detail'], ['as' => 'news.detail']);
Model — App/Model/Http/NewsModel.php: one line, like a Laravel model.
class NewsModel extends EntryModel { protected string $contentTypeSlug = 'news'; }
Controller — App/Controller/Http/News.php
public function index(): string { $posts = NewsModel::published()->latest()->paginate(10); return $this->render('frontend.news.index', compact('posts')); } public function detail(string $slug): string { $post = NewsModel::published()->where('slug', $slug)->firstOrFail(); return $this->render('frontend.news.detail', compact('post')); }
Queries — static calls start a new query, like Laravel; every CodeIgniter query method chains too:
NewsModel::published()->latest()->paginate(10); // + {!! pagination() !!} in the view NewsModel::published()->where('news_type', 'event')->latest()->limit(3)->get(); NewsModel::published()->whereJsonContains('news_category', 'company')->get(); // checkbox / relation fields NewsModel::published()->where('slug', $slug)->firstOrFail(); // 404 when not found NewsModel::query()->where('status', 'draft')->oldest()->get(); // all entries incl. drafts NewsModel::query()->findOrFail($id);
published() = status 公開, publish date reached, publish end date (if any) not reached. Scopes: published, latest, oldest, whereJsonContains.
Views — View/frontend/news/ show each field kind:
{{ $post->news_title }} {{-- text --}} {!! nl2br(esc($post->news_summary)) !!} {{-- textarea: escaped, line breaks kept --}} {{ $post->label('news_type') }} {{-- radio / select: label of the stored value --}} @foreach($post->label('news_category') as $c) … @endforeach {{-- checkbox: list of labels --}} @if($post->news_pickup) … @endif {{-- on/off: true / false --}} <img src="{{ media_url($post->news_image) }}"> {{-- image --}} <div class="ck-content">{!! $post->news_detail !!}</div> {{-- CKEditor: trusted HTML --}} @foreach($post->field('news_links', []) as $link) {{-- repeater: rows as arrays --}} <a href="{{ $link['link_url'] }}">{{ $link['link_label'] }}</a> @endforeach @foreach($post->relation('news_related') as $related) {{-- relation: linked entries (published) --}} <a href="{{ url_to('news.detail', $related->slug) }}">{{ $related->title }}</a> @endforeach
Never write
{!! !!}or{{ }}inside a{{-- comment --}}: BladeOne reads them before it removes comments.
Generators
php spark make:cms-model Store # App/Model/Http/StoreModel.php — content type slug "store" php spark make:cms-controller Store # App/Controller/Http/Store.php — uses StoreModel, empty index() / detail()
| Option | Example |
|---|---|
--slug |
make:cms-model Shop --slug my-shop (default: from the name, ProductItem → product-item) |
--model |
make:cms-controller ProductItem --model Store |
--force |
overwrite an existing file (otherwise existing files are never touched) |
Then create the content type with that slug in the admin, add the routes, and write the views.
Content types and custom fields
An Admin creates content types under 設定 → コンテンツタイプ. Each gets a sidebar menu item and list/add/edit screens.
Field types: テキスト, テキストエリア, 本文 (CKEditor), 数値, メール, URL, 日付, 画像, ファイル, セレクト, ラジオ, チェックボックス, ON/OFF, 関連付け (relation), グループ, リピーター. Groups and repeaters nest up to 3 levels. Fields can be shown or required only when conditions match (AND within a group, OR between groups), in the form and on the server.
Storage — a real table per type:
| Content type / field | Database |
|---|---|
Type store |
table {prefix}store: id, title, slug, status, author_id, meta_title, meta_description, published_at, created_at, updated_at, deleted_at + one column per field |
Group company → address |
column company_address |
Repeater faq |
table {prefix}store__faq (id, parent_id, sort + sub-fields) |
Saving a type creates/alters its tables. Renaming a field keeps its data; deleting a field drops the column after a confirmation.
Reading entries:
entries('store')->where('price <', 1000)->orderBy('price')->paginate(12); // published only entry('store', $slug); // one published entry or null content_type_exists('store');
{{ $item->title }} {{ $item->price }} {{-- typed: numbers, booleans, arrays --}} {!! $item->body !!} {{-- 本文 (CKEditor) HTML — wrap in class="ck-content" --}} <img src="{{ media_url($item->photo) }}"> {{-- 画像 --}} {{ $item->label('color') }} {{-- label of a セレクト / ラジオ value --}} {{ implode(', ', $item->label('tags')) }} {{-- チェックボックス labels --}} @foreach($item->field('faq', []) as $row) {{-- リピーター rows --}} {{ $row['question'] }} @endforeach {!! nl2br(esc($item->comment)) !!} {{-- テキストエリア with line breaks, escaped --}}
Use
$item->field('repeater', [])for repeaters, not$item->repeater ?? [](??can't see repeater fields).
Relation (関連付け) links entries of another content type (or the same one). In the builder choose the target type and 「複数選択できる」 (off: one entry, on: several). Stored as a JSON list of ids:
{{ $post->relation('shop')?->title }} {{-- single → Entry or null --}} @foreach($post->relation('tags') as $tag) … @endforeach {{-- multiple → list of entries --}}
NewsModel::published()->whereJsonContains('shop', 5)->get(); // entries linked to entry 5
Publish period: every entry has 公開日時 and an optional 公開終了日時. Empty end = no end; once the end passes, the entry disappears from the site (lists, detail pages, sitemap, relations).
Preview template (コンテンツタイプ → プレビュー用テンプレート): empty = frontend.{slug}.detail; a template name for detail pages; or a page URL like /recruit for list pages — the real controller renders the page with the unsaved entry swapped in.
Routing, views and Blade
Routes are Laravel-style (routes/web.php). Placeholders (:num), (:segment), (:any) are passed to the method. Build URLs with url_to('name', $param). Every GET route without placeholders goes into sitemap.xml automatically (exclusions in App/Config/Sitemap.php).
Pages without their own controller (a page that is only a Blade view) need one route line — the URL picks the view:
$routes->get('about', [Page::class, 'show'], ['as' => 'about']); // View/frontend/about.blade.php (or about/index.blade.php) $routes->get('company/access', [Page::class, 'show'], ['as' => 'company.access']);
Pages extend the layout and fill its sections:
@extends('frontend.layout.default') @section('title', '会社概要 | ' . $site->name) @section('description', '…') @section('robots', 'noindex, nofollow') {{-- optional --}} @section('og_image', media_url($item->photo)) {{-- optional --}} @push('styles') <link rel="stylesheet" href="{{ asset('assets/frontend/css/about.css') }}"> @endpush @push('scripts') <script src="{{ asset('assets/frontend/js/about.js') }}"></script> @endpush @section('content') … @endsection
{{ $x }}escapes;{!! $html !!}outputs raw HTML — only for CKEditor fields, or{!! nl2br(esc($text)) !!}.@csrfin every POST form;@method('PUT'),@error('field') {{ $message }} @enderror,old('field'),@class([...]),@checked(),@selected()work like Laravel.- Helpers:
$site->name,setting('key'),asset(),url(),url_to(),url_is('news*'),media_url(),format_date(),ja_date(),time_ago(),entries(),entry(). - Always use full view names:
$this->render('frontend.store.detail', [...]).
| Laravel | SurexCore |
|---|---|
route('name', $p) |
url_to('name', $p) |
config('app.name') |
$site->name / setting('site_name') |
request()->is('news*') |
url_is('news*') |
@include('partials.x') |
@include('frontend.layout.x') (full path under View/) |
Frontend assets (Sass / JS)
The layout loads css/style.min.css and js/script.min.js (plus jQuery and WOW from lib/). Build them from the sources with Prepros (prepros.config is included) or from the command line:
cd public/assets/frontend
npx sass scss/style.scss css/style.min.css --style=compressed --no-source-map --load-path=scss
npx esbuild js/script.js --bundle --minify --format=iife --outfile=js/script.min.js
js/script.js imports the modules in js/module/ and runs them. base.js swaps img.spimg between _pc / _sp file names at 768px:
<img class="spimg" src="{{ asset('assets/frontend/images/top/mv_pc.jpg') }}" alt="">
Page-only styles go in their own file, pushed from the view: @push('styles') <link rel="stylesheet" href="{{ asset('assets/frontend/css/news.css') }}"> @endpush.
Forms and mail
App/Controller/Http/Contact.php + View/frontend/contact/ is a complete 入力 → 確認 → 完了 form: CSRF, honeypot, 3 sends per IP per 10 minutes, an admin mail (to cms.adminEmail) and an automatic reply.
Rules are defined in the controller, Laravel-style, including required_if[field,value] / required_unless[...]:
protected array $fields = [ 'email' => ['label' => 'メールアドレス', 'rules' => 'required|valid_email|max_length[191]'], 'other' => ['label' => 'その他', 'rules' => 'required_if[type,other]|max_length[100]'], ];
Mails are plain-text Blade files (View/frontend/contact/mail/) that start with their headers:
From: {{ $fromName }} <{{ $fromEmail }}>
Reply-To: {{ $data['email'] }}
Cc:
Bcc:
Subject: 【{{ $siteName }}】お問い合わせがありました
本文…
Send any template with send_mail_template($to, 'frontend.contact.mail.admin', $data), or HTML with send_mail($to, $subject, $html). Attach files with the option ['attachments' => [[$path, $fileName]]].
File attachment: the contact form has an optional 添付ファイル (PDF, images, Office, TXT/CSV, ZIP; 5 MB). The type is checked from the file contents; the file waits in writable/uploads/contact/ (not public), is sent with the admin mail and then deleted.
お問い合わせ inbox: 一般設定 → 「お問い合わせを管理画面に保存する」. When on, every sent form is also saved and a 「お問い合わせ」 menu (with an unread count) appears for both roles: list, detail, attachment download, 未読に戻す, delete. Mail is sent either way. Other forms can save into the same inbox:
model(InquiryModel::class)->store('recruit', ['お名前' => $name, …], $name, $email, $attachment);
Admin panel features
The admin panel is available in English and Japanese — English by default, switch any time from the top bar (remembered per browser). The menu labels below are shown in Japanese for reference.
| Menu | Who | What |
|---|---|---|
| ダッシュボード | all | stats, charts, recent entries; 管理者 also see activity, system status and disk usage |
| (content types) | all | entries: list, add, edit, preview, 複製, delete → ゴミ箱 (restore / delete for good / empty); repeater rows fold up (collapsed on open, first value shown in the header) |
| お問い合わせ | all | saved form submissions (only while 「お問い合わせを保存する」 is on) |
| メディア | all | media library; JPEG/PNG uploads are saved as WebP (cms.convertToWebp) |
| ブランディング | all | site logo (setting('site_logo')): PNG, JPG, WebP, GIF or SVG (SVG is sanitized and served sandboxed) |
| 設定 → 一般設定 | 管理者 | site name, date format, maintenance mode, 検索エンジンにインデックスさせない, test mail |
| 設定 → ユーザー / コンテンツタイプ / 操作ログ | 管理者 | users and roles, content types, activity log |
| 設定 → バックアップ | 管理者 | download the database (.sql) or database + uploads (.zip); deploy packages for the server (初回公開用 / 更新用) |
Upgrading a site from an older version
Update locally first, test, then upload with the Update deploy package (the server's database, uploads and .env stay as they are).
- Unpack the new version. Don't run the installer: copy the old site's
.envandwritable/installed.lock, import its database, and copypublic/uploads/. - Copy your own code into it:
App/Controller/Http/,App/Model/Http/,View/frontend/,public/assets/frontend/, and add your own route lines to the newroutes/web.php(don't replace the file: it contains SurexCore's routes too). - Open the admin once — database changes run automatically. Test, then deploy.
From 0.1.7 or older: your site's controllers and models were directly in App/Controller/ and App/Model/. They keep working there; to use the new layout, move them into the Http/ folders and change their namespaces to App\Controller\Http / App\Model\Http (and the use lines in routes/web.php).
Google Analytics on the dashboard
The dashboard can show Google Analytics 4 numbers, so clients don't have to log in to Google: visitors, sessions, page views and engagement time (with the change against the previous period), visitors per day, devices, top pages, traffic sources and visitors active now — for 7, 28 or 90 days. Administrators and Web admins see it; it is read-only and cached for an hour.
Settings → Analytics (Administrator) explains every step. You need:
- the GA4 property ID — the number in Google Analytics → Admin → Property settings (not the
G-…orGTM-…ID); - a Google Cloud service account with the Google Analytics Data API enabled, and its JSON key file;
- that service account's e-mail added as Viewer in Google Analytics → Admin → Property access management.
Enter the ID, upload the key, press Test connection. To check the layout first, switch on Show sample data (generated numbers, marked “Sample data” on the dashboard; no Google account needed). The key is stored in writable/analytics/ (never served, not in backups or deploy packages — upload it again on a new server).
JSON API (read-only)
Other sites and apps can read published content as JSON. Nothing is exposed until you switch it on: Settings → Content Types → (type) → "Expose REST API" — the form then shows the URLs, and the list marks the type with an API badge. No routes or code to add.
Optional settings in .env:
cms.apiOrigins = 'https://other-site.com' # browser JavaScript on these sites may call it (CORS)
cms.apiKey = '' # require header X-API-Key
cms.apiTypes = 'news' # expose types from .env as well (comma separated slugs)
| Request | Returns |
|---|---|
GET /api/site |
site name, tagline, logo URL, the exposed content types |
GET /api/news?page=1&limit=10 |
published entries, newest first, with meta (total, pages) and links (prev / next) |
GET /api/news/{slug} |
one published entry |
Each entry has id, slug, title, url, published_at, seo and fields (every custom field by name: images as full URLs, choices as {value, label}, relations as {id, slug, title}, repeaters as lists). Drafts, scheduled and ended entries, users, inquiries and settings are never returned. 120 requests per minute per IP.
// Another site (PHP) $news = json_decode(file_get_contents('https://your-site.com/api/news?limit=5'), true)['data'];
Configuration (.env)
The installer creates .env from .env.example. Never commit .env or copy it between sites.
| Key | Meaning |
|---|---|
CI_ENVIRONMENT |
production (errors hidden) or development (debug toolbar) |
app.baseURL |
Site URL with trailing / — e.g. https://example.com/ or http://localhost:8888/surexcore/ |
app.forceGlobalSecureRequests |
true to force HTTPS |
cms.appName |
Name shown in the admin panel and default mail sender name |
cms.adminEmail |
Where contact form mail goes |
cms.adminPath |
Admin URL: https://site/{adminPath}/login. Use your own word to hide it; /admin then shows 404 |
database.default.* |
Database connection and table prefix |
encryption.key |
Random per site (php spark key:generate --show) |
email.* |
SMTP for mail. Test it in 設定 → 一般設定 |
Database changes (automatic migrations)
Add a migration to App/Database/Migrations/ (php spark make:migration Name). After deploying, the next admin page load runs pending migrations automatically — no SSH needed. Content types never need migrations; the admin creates their tables.
Deploying to shared hosting
SurexCore runs on ordinary shared hosting (tested on Hostinger): PHP 8.2+, MySQL/MariaDB and Apache or LiteSpeed. No SSH, Composer or Node is needed on the server.
1. Prepare the server
| Check | Where (e.g. Hostinger hPanel) |
|---|---|
PHP 8.2+, extensions intl, mbstring, mysqli, curl, fileinfo, gd, zip |
PHP configuration |
An empty database + user (note name, user, password — usually prefixed, e.g. u123456789_site) |
Databases → MySQL |
| SSL certificate active on the domain | Security → SSL |
Document root: can it point to public/? |
Domains / Websites |
Document root. Best: point the domain at the project's public/ folder. If the host only allows public_html/ (common), put the whole project into public_html/ — the root .htaccess sends every request into public/, so .env, App/ and vendor/ are never reachable from the web.
public_html/
├── .htaccess ← routes everything into public/ (must be uploaded!)
├── .env ← created by the installer or by you
├── App/ View/ routes/ vendor/ writable/
└── public/ ← index.php, assets, uploads (with its own .htaccess)
2. Build the upload package
One click, no commands: 設定 → バックアップ → 「サーバーにアップロード(デプロイ)」
| Button | Contents |
|---|---|
| 初回公開用(サイト一式) | program + vendor/ + public/uploads/ + _deploy/database.sql + _deploy/env-server.txt (your settings with a new encryption key; passwords left as ●●) + _deploy/README.txt |
| 更新用(プログラムのみ) | program + vendor/ — the server's database, uploads and .env are untouched |
Only what the server needs is packed (.htaccess, App/, View/, routes/, public/, vendor/, writable/ folders, spark, composer.*): never .env, logs, cache, sessions, .git, docker/ or your own notes. Extract the zip into the document root, follow _deploy/README.txt, then delete _deploy/.
From the command line (Docker running locally), deploy.sh builds the same kind of zip:
./deploy.sh fresh # new empty site — the installer runs on the server ./deploy.sh full # a site you built locally — code + public/uploads + database (.sql) + server .env template ./deploy.sh code # updates to a live site — code only (the server's content, uploads and .env are kept)
Output goes to deploy/ (surexcore-{mode}-YYYYmmdd-HHMM.zip, and for full also database-….sql and env-server-….txt).
Without Docker: use the release zip, or zip the project yourself after composer install --no-dev (leave out .env, writable/cache|logs|session|debugbar/* and deploy/).
3. Upload — always extract on the server
- Empty
public_html/(delete the host'sdefault.php/index.html). - Upload the zip file with the host's File Manager and Extract it there (folder name:
., i.e. intopublic_html/itself). Then delete the zip.
⚠️ Don't unzip on a Mac/PC and upload the folder: hidden files like
.htaccessare easily skipped. Without them every page returns 403. In the File Manager, turn on Show hidden files and checkpublic_html/.htaccessandpublic_html/public/.htaccessexist.
4a. New site (fresh) — run the installer
Open https://your-domain/. The installer asks for the database (host usually localhost), site name and the first 管理者 account, writes .env with a new encryption key, creates all tables and locks itself.
4b. Site built locally (full) — import + .env
- Import
database-….sqlwith phpMyAdmin (select the database → インポート). The file replaces every table with your local data, except the activity log (the server keeps its own); tables are dropped in a safe order, so re-importing works too. - Create
.envfromenv-server-….txt(it already contains a new encryption key). Easiest: File Manager → New File → name it exactly.env(not.env.txt) inpublic_html/→ paste → fill in every●●:
CI_ENVIRONMENT = production app.baseURL = 'https://your-domain.com/' # https, trailing slash app.forceGlobalSecureRequests = true cms.adminEmail = 'info@your-domain.com' cms.adminPath = 'your-secret-word' # admin at /your-secret-word/login, /admin → 404 database.default.hostname = 'localhost' database.default.database = 'u123456789_site' # full names from the hosting panel database.default.username = 'u123456789_user' database.default.password = '…' email.SMTPHost = 'smtp.your-host.com' # e.g. smtp.hostinger.com, port 465 + ssl
public/uploads/is already in thefullzip. The database andpublic/uploads/always belong together — the.sqlonly stores the paths of images.
5. After deploying — checklist
- Top page, a few pages and the 404 page load
- Log in at
https://your-domain/{adminPath}/login(opening the admin also runs pending migrations) - 設定 → 一般設定 → send a test mail; send the contact form once
- 設定 → 一般設定 → 「検索エンジンにインデックスさせない」 is off (on for test sites)
-
https://your-domain/sitemap.xmllists your real domain — submit it in Google Search Console -
writable/is writable (755) — logs appear inwritable/logs/
6. Updating a live site
./deploy.sh code
Upload and extract the zip over public_html/. Code, templates and assets are replaced; the server's .env, database and public/uploads/ are untouched. New migrations run on the next admin page load. You can also upload single changed files with the File Manager.
After go-live the server database is the source of truth — clients edit content there. Never import your local database again; take backups from 設定 → バックアップ instead.
Troubleshooting
| Symptom | Cause → fix |
|---|---|
| 403 on every page | Root .htaccess missing (unzipped locally) → extract the zip on the server, check hidden files |
500, log says Access denied for user ''@'localhost' |
.env not read: wrong name/location or ●● left → public_html/.env, exact name .env |
500, Access denied for user 'u…' |
Wrong DB name/user/password → copy them exactly from the hosting panel |
| 500 right after upload | Read writable/logs/log-YYYY-MM-DD.log; check PHP is 8.2+ and writable/ is writable |
| No input file specified. on every page except the top page | FastCGI/PHP-FPM host + an old public/.htaccess → the rewrite rule must be index.php?/$1 (with ?), as in SurexCore 0.1.0 |
vendor/…/Boot.php missing |
vendor/ not uploaded → use the release/deploy zip, or composer install |
| Images broken after moving a site | public/uploads/ not copied, or app.baseURL still points to the old URL |
Import error #3730 Cannot drop table … foreign key |
Old dump format → re-export with ./deploy.sh full or 設定 → バックアップ (or untick 「外部キーのチェックを有効にする」 in phpMyAdmin) |
| Mail not sent | 設定 → 一般設定 → test mail; SMTP port 465 + ssl or 587 + tls |
Security
SurexCore is built to be safe to run on shared hosting out of the box:
- Locked-down web server — only the public area is reachable; configuration, source and backup files are never served, directory listing is off, and the uploads area can never execute code.
- Strong HTTP headers — protection against clickjacking, MIME-sniffing and information leakage, a strict Content-Security-Policy for the admin panel, and HSTS on HTTPS.
- CSRF protection on every form, plus automatic output escaping to prevent cross-site scripting (XSS).
- Secure sessions & cookies — HttpOnly, SameSite and Secure-on-HTTPS cookies, a neutral session name, and the old session destroyed on login.
- Safe passwords & login — modern password hashing, a minimum length, login rate limiting, and generic error messages that don't reveal whether an account exists.
- Brute-force protection — an IP that fails to log in 3 times is blocked from the admin panel until an admin unblocks it (Settings → Security, with an allow list for your own IPs); the admins get an email, login IDs under attack from many IPs are locked for a while, and changing a password signs out every other session of that account.
- Hidden admin URL, the installer locked after setup, and errors hidden in production.
- Sanitized rich text — editor HTML is cleaned on save (no scripts, iframes, event handlers or dangerous links).
- Safe uploads — the file type is detected from the contents (not the name), files get random names, and images are re-encoded to strip metadata (EXIF/GPS) and hidden payloads. SVG is only accepted for the site logo: scripts, event handlers and external links are stripped, and it is served with a sandboxing Content-Security-Policy.
- Private attachments — contact-form files and saved inquiries are kept out of public reach and are only downloadable by logged-in admins.
- Activity log of logins (including failures) and changes, and no plugin system — no third-party plugin code to attack.
On every live site: run in production mode, use HTTPS, choose a unique admin path, set strong passwords, and keep dependencies updated with regular backups.
License
SurexCore is released under the MIT License (see LICENSE).
Included third-party software keeps its own license: CodeIgniter 4, BladeOne, Bootstrap, AdminLTE, Bootstrap Icons, Chart.js and flatpickr (MIT).
CKEditor 5 (public/assets/vendor/ckeditor5/) is used with its open-source GPL license key (licenseKey: 'GPL') and stays under GPL-2.0-or-later.
If you build a closed-source / commercial product on SurexCore, get a commercial CKEditor license or replace the editor.
Feedback
SurexCore is shared as a developer preview — your feedback decides what comes next. Useful things to report:
- Bugs (what you did, what you expected, what happened, screenshots,
writable/logs/) - Anything confusing in the admin panel, the field builder or this README
- Missing features you needed while building a real site
- Hosting environments where installation or deployment failed
Developed by Suresh Mayanglambam.