How to structure a fast, secure core PHP application with MVC: the front controller, routing, safe database access, and when to use a framework.
Frameworks such as Laravel and Symfony are excellent, but they are not the only professional way to build a PHP application. A small, well-organised core PHP project can be fast, easy to host and simple to hand over, provided it follows a clear structure. MVC gives you that structure.
What MVC means in practice
MVC divides your code by responsibility, so each kind of change has one obvious home:
- Model: data and business rules, such as how an invoice total is calculated.
- View: the HTML the user sees, with as little logic as possible.
- Controller: receives the request, calls the models and chooses the view.
The benefit shows up months later. When tax logic lives in one model rather than being repeated across twelve pages, a rate change is a one-line fix.
A folder structure that works
project/
├── app/
│ ├── Controllers/
│ ├── Models/
│ ├── Views/
│ └── core/ (Router, Controller, Security)
├── config/
├── storage/ (logs, uploads)
└── public/ (the only folder the web can reach)
├── index.php
├── css/
└── js/
The most important detail is the last line. Your web server should point at public/, which keeps configuration files, logs and source code out of reach even if someone guesses their paths. On shared hosting where you cannot change the document root, block those folders with .htaccess rules and test that they really return "Forbidden".
One front door for every request
In the front controller pattern, every request passes through public/index.php. Security headers, sessions and error handling therefore live in one place and cannot be forgotten on a new page.
// public/index.php
require __DIR__ . '/../config/config.php';
require __DIR__ . '/../app/core/Router.php';
$router = new Router();
$router->get('/', 'HomeController', 'index');
$router->get('/invoices', 'InvoiceController', 'index');
$router->post('/invoices', 'InvoiceController', 'store');
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$router->dispatch($uri, $_SERVER['REQUEST_METHOD']);
A list of routes also doubles as a map of the application, which is very useful to the next developer. Treat it as an allow-list too: never build a file path or class name directly from user input.
Keep controllers thin
A common mistake is putting everything into the controller. Keep it short and let the model do the work.
class InvoiceController extends Controller {
public function store(): void {
$data = Invoice::validate($_POST);
$id = Invoice::create($this->db, $data);
$this->redirect("/invoices/$id");
}
}
Written this way, the invoice logic can be reused by a command-line job or an API, and tested without a browser.
Security you must build yourself
A framework gives you many protections by default. In core PHP they are your responsibility, so make them part of the base code from day one.
| Risk | What to do |
|---|---|
| SQL injection | Use PDO prepared statements for every query. Never join user input into SQL. |
| Cross-site scripting | Escape all output with a short helper around htmlspecialchars(), and add a Content-Security-Policy header. |
| Forged form submissions | Add a random CSRF token to each form and verify it on the server. |
| Stolen passwords | Store them with password_hash(), and check them with password_verify(). |
| Leaked errors | Turn display_errors off in production and write errors to a private log. |
// Safe: the value is sent separately from the SQL
$stmt = $db->prepare('SELECT * FROM invoices WHERE customer_id = ?');
$stmt->execute([$customerId]);
What makes it scale
PHP handles each request separately, which makes adding capacity straightforward. Slow applications usually have a database problem, so look there first:
- Index the columns you search and join on. Run
EXPLAINon slow queries. What works with 100 rows can crawl at 100,000. - Avoid queries inside loops. Fetch related rows together rather than running one query per row.
- Paginate every list. Never load a whole table to show 20 rows.
- Turn on OPcache, which stores compiled PHP in memory.
- Move slow work out of the request. Bulk SMS or large reports belong in a queue run by a scheduled job.
- Keep sessions and uploads off the web server's local disk if you may ever run more than one server.
When a framework is the better choice
Core PHP is not always the right answer. A framework is usually the better choice when:
- Several developers will work on the project, and shared conventions will save time.
- You need many ready-made features, such as queues, a full authentication system, or scheduled tasks.
- The application will grow for years and you will want its community packages and security updates.
Core PHP suits small to medium applications where you want full control, few dependencies and cheap hosting. If you do choose it, use Composer for autoloading and for well-tested libraries, such as a mail library, instead of rewriting everything.
Next step
Start small: a front controller, a router, a base controller and PDO. Add a piece only when the project needs it. If you would rather have a business system built for you, see our business software products or tell us about your project.
Further reading: PHP manual: PDO · PHP manual: password_hash · PHP manual: OPcache