This is the folder structure

Now create a database table called mvc2 and paste this sql commands:
CREATE TABLE `articles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`content` TEXT NOT NULL,
`author_id` INT UNSIGNED NULL, -- Allow NULL values now
`image_path` VARCHAR(255) DEFAULT NULL, -- For image upload (if applicable)
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `author_id` (`author_id`),
CONSTRAINT `articles_ibfk_1` FOREIGN KEY (`author_id`)
REFERENCES `users` (`id`)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`password` VARCHAR(255) NOT NULL,
`role` ENUM('admin','user','editor') NOT NULL DEFAULT 'user',
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
first install the composer by typing in your project folder “composer init”. Then on the composer.json file add this:
{
"name": "farhanabintashaheed/mvc_project",
"require": {
"vlucas/phpdotenv": "^5.5",
"symfony/var-dumper": "^5.3",
"respect/validation": "^2.0",
"pecee/simple-router": "^5.4",
"samayo/bulletproof": "^5.0"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"authors": [
{
"name": "FarhanaShaheed",
"email": "farhana15-734@diu.edu.bd"
}
]
}
Index.php:
<?php
require_once __DIR__ . '/../App/core/config.php';
use Pecee\SimpleRouter\SimpleRouter;
require_once __DIR__ . '/../vendor/autoload.php';
session_start();
// Optional: Enable error display for debugging during development.
// ini_set('display_errors', 1);
// ini_set('display_startup_errors', 1);
// error_reporting(E_ALL);
// (If your project runs in a subfolder, you might need to adjust the URI manually)
// For example, if accessing via http://localhost/mvc_project/public/, you can strip the subfolder:
// $basePath = '/mvc_project/public';
// if (strpos($_SERVER['REQUEST_URI'], $basePath) === 0) {
// $_SERVER['REQUEST_URI'] = substr($_SERVER['REQUEST_URI'], strlen($basePath));
// }
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Set the default namespace for controllers.
SimpleRouter::setDefaultNamespace('\App\Controllers');
// Load the routes file.
require_once __DIR__ . '/../app/routes.php';
// Start the routing process.
SimpleRouter::start();
.htaccess file:
RewriteEngine On
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-l
RewriteRule ^(.*)$ index.php/$1 [QSA,L]
view/create.php:
<?php include __DIR__ . '/header.php'; ?>
<h1>Create Article</h1>
<form method="post" action="/create" enctype="multipart/form-data">
<label for="title">Title:</label>
<input type="text" name="title" required>
<br>
<label for="content">Content:</label>
<textarea name="content" rows="10" cols="50" required></textarea>
<br>
<!-- Image upload field -->
<label for="pictures">Upload an Image:</label>
<input type="file" name="pictures" id="pictures" accept="image/*" required>
<br>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token']; ?>">
<button type="submit">Create</button>
</form>
<?php include __DIR__ . '/footer.php'; ?>
view/edit.php:
<?php include __DIR__ . '/header.php'; ?>
<h1>Edit Article</h1>
<form method="post" action="/edit/<?= htmlspecialchars($article['id']); ?>" enctype="multipart/form-data">
<label for="title">Title:</label>
<input type="text" name="title" value="<?= htmlspecialchars($article['title']); ?>" required>
<br>
<label for="content">Content:</label>
<textarea name="content" rows="10" cols="50" required><?= htmlspecialchars($article['content']); ?></textarea>
<br>
<br>
<?php if (!empty($article['image_path'])): ?>
<div>
<p>Current Image:</p>
<img src="/<?= htmlspecialchars($article['image_path']); ?>" alt="Current Image" style="max-width:200px;">
</div>
<?php endif; ?>
<label for="pictures">Upload a New Image (optional):</label>
<input type="file" name="pictures" id="pictures" accept="image/*">
<br>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token']; ?>">
<button type="submit">Update</button>
</form>
<?php include __DIR__ . '/footer.php'; ?>
view/home.php
<?php include __DIR__ .'/header.php'?>
<h1>All Articles:</h1>
<?php foreach($articles as $article):?>
<a href="/view/<?=htmlspecialchars($article['id']);?>">
<?= htmlspecialchars($article['title']); ?></a>
<p> By: <?= htmlspecialchars($article['author']) ?></p>
<p><?= nl2br(htmlspecialchars(substr($article['content'], 0,200))) ?></p>
<?php endforeach?>
<div>
<?php if ($page > 1): ?>
<a href="/?page=<?= $page - 1; ?>">Previous</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
<?php if ($i == $page): ?>
<strong><?= $i ?></strong>
<?php else: ?>
<a href="/?page=<?= $i ?>"><?= $i ?></a>
<?php endif; ?>
<?php endfor; ?>
<?php if ($page < $totalPages): ?>
<a href="/?page=<?= $page + 1; ?>">Next</a>
<?php endif; ?>
</div>
<?php include __DIR__ . '/footer.php'; ?>
view/view.php
<?php include __DIR__ . '/header.php'; ?>
<h1><?= htmlspecialchars($article['title']); ?></h1>
<?php if (!empty($article['image_path'])): ?>
<div>
<img src="/<?= htmlspecialchars($article['image_path']); ?>" alt="Image for <?= htmlspecialchars($article['title']); ?>">
</div>
<?php endif; ?>
<p>By: <?= htmlspecialchars($article['author']); ?></p>
<p><?= nl2br(htmlspecialchars($article['content'])); ?></p>
<?php if (isset($_SESSION['user']) && ($_SESSION['user']['id'] == $article['author_id'] || $_SESSION['user']['role'] === 'admin')): ?>
<a href="/edit/<?= htmlspecialchars($article['id']); ?>">Edit</a>
<form method="post" action="/delete/<?= htmlspecialchars($article['id']); ?>" style="display:inline;">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token']; ?>">
<button type="submit">Delete</button>
</form>
<?php endif; ?>
<?php include __DIR__ . '/footer.php'; ?>
views/login.php:
<?php include __DIR__ . '/header.php'; ?>
<h1>Login</h1>
<form method="post" action="/login">
<label for="username">Username:</label>
<input type="text" name="username" required>
<br>
<label for="password">Password:</label>
<input type="password" name="password" required>
<br>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token']; ?>">
<button type="submit">Login</button>
</form>
<?php include __DIR__ . '/footer.php'; ?>
view/signup.php:
<?php include __DIR__ . '/header.php'; ?>
<h1>Sign Up</h1>
<form method="post" action="/signup">
<label for="username">Username:</label>
<input type="text" name="username" required>
<br>
<label for="password">Password:</label>
<input type="password" name="password" required>
<br>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token']; ?>">
<button type="submit">Sign Up</button>
</form>
<?php include __DIR__ . '/footer.php'; ?>
views/header.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My MVC Project</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<nav>
<a href="/">Home</a>
<?php if(isset($_SESSION['user'])): ?>
<span>Welcome, <?= htmlspecialchars($_SESSION['user']['username']); ?></span>
<a href="/create">Create Article</a>
<a href="/logout">Logout</a>
<?php else: ?>
<a href="/login">Login</a>
<a href="/signup">Sign Up</a>
<?php endif; ?>
</nav>
<hr>
views/footer.php:
<hr>
<footer>
<p>© 2025 My MVC Project</p>
</footer>
</body>
</html>
config/database.php:
<?php
// app/config/Database.php
namespace App\Config;
use PDO;
use PDOException;
class Database {
private static ?PDO $instance = null;
public static function getConnection(): PDO {
if (self::$instance === null) {
// Update DSN, username, and password per your environment
// $dsn = 'mysql:host=localhost;dbname=mvc1';
// $username = 'root';
// $password = '';
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$username = DB_USER;
$password = DB_PASS;
try {
self::$instance = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
}
return self::$instance;
}
}
core/config.php
<?php
// Define the environment.
if (in_array($_SERVER['SERVER_NAME'], ['localhost', '127.0.0.1'])) {
define('ENVIRONMENT', 'development');
} else {
define('ENVIRONMENT', 'production');
}
// Error reporting configuration.
if (ENVIRONMENT === 'development') {
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
//Development database connection:
define('DB_HOST', 'localhost');
define('DB_NAME', 'mvc1');
define('DB_USER', 'root');
define('DB_PASS', '');
define('PUBLIC_UPLOADS', 'uploads/');
} else {
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
error_reporting(0);
// production Database details:
define('DB_HOST', 'localhost');
define('DB_NAME', 'mvc2');
define('DB_USER', 'root');
define('DB_PASS', '');
define('PUBLIC_UPLOADS', 'uploads/');
}
// Database configuration.
Controllers/ArticleController.php
<?php
// app/controllers/ArticleController.php
namespace App\Controllers;
use App\Config\Database;
use App\Models\Article;
use Respect\Validation\Validator as v;
use Bulletproof\Image;
class ArticleController {
public function view($id) {
$db = Database::getConnection();
$articleModel = new Article($db);
$article = $articleModel->findById($id);
if (!$article) {
die("Article not found.");
}
include __DIR__ . '/../Views/view.php';
}
public function create() {
// Only logged-in users can create articles.
if (!isset($_SESSION['user'])) {
header("Location: /login");
exit();
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
echo "<pre>";
print_r($_FILES);
echo "</pre>";
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die("Invalid CSRF token.");
}
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
// Validate title and content.
$titleValidator = v::stringType()->length(5, 100);
$contentValidator = v::stringType()->length(10, null);
if (!$titleValidator->validate($title) || !$contentValidator->validate($content)) {
die("Invalid input. Check your title and content.");
}
// Process image upload using Bulletproof.
$image = new Image($_FILES);
// Set the storage path for uploaded images.
$image->setStorage(__DIR__ . '/../../public/uploads');
$image->setSize(1, 50000000);
$image->setDimension(50000, 50000);
if ($image["pictures"]) {
$upload = $image->upload(); // Using the default documented usage.
if ($upload) {
// Get only the file name from the full path.
$filename = basename($upload->getPath());
// Build the relative path – we know our images are stored in "uploads/".
$imagePath = 'uploads/' . $filename;
} else {
die("Image upload failed: " . $image->getError());
}
} else {
$imagePath = null;
}
$db = Database::getConnection();
$articleModel = new Article($db);
$authorId = $_SESSION['user']['id'];
$success = $articleModel->create([
'title' => $title,
'content' => $content,
'author_id' => $authorId,
'image_path' => $imagePath
]);
if ($success) {
header("Location: /");
exit();
} else {
die("Failed to create the article.");
}
} else {
include __DIR__ . '/../Views/create.php';
}
}
public function edit($id) {
if (!isset($_SESSION['user'])) {
header("Location: /login");
exit();
}
$db = Database::getConnection();
$articleModel = new Article($db);
$article = $articleModel->findById($id);
if (!$article) {
die("Article not found.");
}
// Check if the current user is the author or an admin.
if ($_SESSION['user']['id'] != $article['author_id'] && $_SESSION['user']['role'] !== 'admin') {
die("Access denied.");
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die("Invalid CSRF token.");
}
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$titleValidator = v::stringType()->length(5, 100);
$contentValidator = v::stringType()->length(10, null);
if (!$titleValidator->validate($title) || !$contentValidator->validate($content)) {
die("Invalid input.");
}
// Process new image upload if provided.
$image = new Image($_FILES);
$image->setStorage(__DIR__ . '/../../public/uploads');
$image->setSize(1, 50000000);
$image->setDimension(50000, 50000);
if ($image["pictures"] && !empty($_FILES["pictures"]["name"])) {
$upload = $image->upload();
if ($upload) {
// Use basename() to extract the filename.
$filename = basename($upload->getPath());
// Prepend the directory ("uploads/") to make a relative URL.
$imagePath = 'uploads/' . $filename;
} else {
die("Image upload failed: " . $image->getError());
}
}
$success = $articleModel->update($id, [
'title' => $title,
'content' => $content,
'image_path'=>$imagePath
]);
if ($success) {
header("Location: /view/$id");
exit();
} else {
die("Failed to update the article.");
}
} else {
include __DIR__ . '/../views/edit.php';
}
}
public function delete($id) {
if (!isset($_SESSION['user'])) {
header("Location: /login");
exit();
}
$db = Database::getConnection();
$articleModel = new Article($db);
$article = $articleModel->findById($id);
if (!$article) {
die("Article not found.");
}
if ($_SESSION['user']['id'] != $article['author_id'] && $_SESSION['user']['role'] !== 'admin') {
die("Access denied.");
}
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die("Invalid CSRF token.");
}
$success = $articleModel->delete($id);
if ($success) {
header("Location: /");
exit();
} else {
die("Failed to delete the article.");
}
}
}
Controllers/HomeController.php
<?php
// app/controllers/HomeController.php
namespace App\Controllers;
use App\Config\Database;
use App\Models\Article;
class HomeController {
public function index() {
$db = Database::getConnection();
$articleModel = new Article($db);
// Set pagination: 5 articles per page.
$limit = 5;
$page = isset($_GET['page']) ? max((int)$_GET['page'], 1) : 1;
$offset = ($page - 1) * $limit;
// Fetch articles and total count.
$articles = $articleModel->getArticles($limit, $offset);
$totalArticles = $articleModel->countArticles();
$totalPages = ceil($totalArticles / $limit);
// Include the view (variables are available in the view).
include __DIR__ . '/../views/home.php';
}
}
Controllers/UserController.php
<?php
// app/controllers/UserController.php
namespace App\Controllers;
use App\Config\Database;
use App\Models\User;
use Respect\Validation\Validator as v;
class UserController {
public function loginForm() {
include __DIR__ . '/../views/login.php';
}
public function login() {
// CSRF check using session token.
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die("Invalid CSRF token.");
}
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
// Validate input.
$usernameValidator = v::alnum()->noWhitespace()->length(3, 20);
$passwordValidator = v::length(6, 30);
if (!$usernameValidator->validate($username) || !$passwordValidator->validate($password)) {
die("Invalid input.");
}
$db = Database::getConnection();
$userModel = new User($db);
$user = $userModel->findByUsername($username);
if ($user && password_verify($password, $user['password'])) {
$_SESSION['user'] = $user;
header("Location: /");
exit();
} else {
die("Authentication failed.");
}
}
public function signupForm() {
include __DIR__ . '/../views/signup.php';
}
public function signup() {
if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die("Invalid CSRF token.");
}
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
// Validate inputs.
$usernameValidator = v::alnum()->noWhitespace()->length(3, 20);
$passwordValidator = v::length(6, 30);
if (!$usernameValidator->validate($username) || !$passwordValidator->validate($password)) {
die("Invalid input.");
}
$db = Database::getConnection();
$userModel = new User($db);
if ($userModel->findByUsername($username)) {
die("Username already exists.");
}
// Register the new user; default role is "user".
$success = $userModel->create([
'username' => $username,
'password' => $password, // Model uses password_hash() internally
'role' => 'user'
]);
if ($success) {
header("Location: /login");
exit();
} else {
die("Registration failed.");
}
}
public function logout() {
unset($_SESSION['user']);
header("Location: /");
exit();
}
}
Models/article.php
<?php
// app/models/Article.php
namespace App\Models;
use PDO;
class Article {
private PDO $db;
public function __construct(PDO $db) {
$this->db = $db;
}
// Create a new article.
public function create(array $data): bool {
$stmt = $this->db->prepare("
INSERT INTO articles (title, content, author_id, image_path)
VALUES (:title, :content, :author_id, :image_path)
");
return $stmt->execute([
':title' => $data['title'],
':content' => $data['content'],
':author_id' => $data['author_id'],
':image_path' => $data['image_path'] ?? null,
]);
}
// Update an article.
public function update($id, array $data): bool {
$stmt = $this->db->prepare("UPDATE articles SET title = :title, content = :content, image_path=:image_path
WHERE id = :id");
return $stmt->execute([
':title' => $data['title'],
':content' => $data['content'],
':image_path' => $data['image_path'] ?? null,
':id' => $id
]);
}
// Delete an article.
public function delete($id): bool {
$stmt = $this->db->prepare("DELETE FROM articles WHERE id = :id");
return $stmt->execute([':id' => $id]);
}
// Find an article by ID.
public function findById($id) {
$stmt = $this->db->prepare("
SELECT a.*, u.username AS author
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
WHERE a.id = :id
LIMIT 1
");
$stmt->execute([':id' => $id]);
return $stmt->fetch();
}
// Get paginated articles (also retrieving the author's username).
public function getArticles($limit, $offset) {
$stmt = $this->db->prepare(
"SELECT a.*, u.username AS author
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
ORDER BY a.id DESC
LIMIT :limit OFFSET :offset"
);
$stmt->bindValue(':limit', (int)$limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', (int)$offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
// Count total articles.
public function countArticles(): int {
$stmt = $this->db->query("SELECT COUNT(*) as count FROM articles");
$result = $stmt->fetch();
return $result ? (int)$result['count'] : 0;
}
}
Model/user.php
<?php
// app/models/User.php
namespace App\Models;
use PDO;
class User {
private PDO $db;
public function __construct(PDO $db) {
$this->db = $db;
}
// Find a user by username.
public function findByUsername($username) {
$stmt = $this->db->prepare("SELECT * FROM users WHERE username = :username LIMIT 1");
$stmt->execute([':username' => $username]);
return $stmt->fetch();
}
// Create a new user with secure password storage.
public function create(array $data): bool {
$stmt = $this->db->prepare("INSERT INTO users (username, password, role)
VALUES (:username, :password, :role)");
return $stmt->execute([
':username' => $data['username'],
':password' => password_hash($data['password'], PASSWORD_DEFAULT),
':role' => $data['role'] ?? 'user'
]);
}
}
routes.php
<?php
// app/routes.php
use Pecee\SimpleRouter\SimpleRouter;
// Set a default namespace for controller callbacks (so you don’t have to prepend "App\Controllers\" each time)
SimpleRouter::setDefaultNamespace('App\Controllers');
// Home page that lists articles (with pagination handled in the controller)
SimpleRouter::get('/', 'HomeController@index')->name('home');
SimpleRouter::get('/home', 'HomeController@index')->name('home');
// User authentication routes
SimpleRouter::get('/login', 'UserController@loginForm')->name('login.form');
SimpleRouter::post('/login', 'UserController@login')->name('login');
SimpleRouter::get('/signup', 'UserController@signupForm')->name('signup.form');
SimpleRouter::post('/signup', 'UserController@signup')->name('signup');
SimpleRouter::get('/logout', 'UserController@logout')->name('logout');
// Article CRUD routes
SimpleRouter::get('/view/{id}', 'ArticleController@view')
->where(['id' => '[0-9]+'])
->name('article.view');
SimpleRouter::match(['get', 'post'], '/create', 'ArticleController@create')
->name('article.create');
SimpleRouter::match(['get', 'post'], '/edit/{id}', 'ArticleController@edit')
->where(['id' => '[0-9]+'])
->name('article.edit');
SimpleRouter::post('/delete/{id}', 'ArticleController@delete')
->where(['id' => '[0-9]+'])
->name('article.delete');
SimpleRouter::get('/not-found', 'PageController@notFound');
SimpleRouter::get('/forbidden', 'PageController@notFound');
SimpleRouter::error(function(Request $request, \Exception $exception) {
switch($exception->getCode()) {
// Page not found
case 404:
response()->redirect('/not-found');
// Forbidden
case 403:
response()->redirect('/forbidden');
default:
response()->httpCode(500);
echo "An unexpected error occurred.";
break;
}
});