How to create a simple php crud project by following MVC pattern

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>&copy; 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;
        }
        
    });

Leave a Reply

Your email address will not be published. Required fields are marked *