A simple MVC framework built for Adderat.
composer installcomposer dump-autoload -oControllers are stored in the app/Controllers directory. Each controller should extend the Core\Controller class.
Views are stored in the app/Views directory.
Models are stored in the app/Models directory. Each model should extend the Core\Model class.
Routes are automatically determined by the URL. The first segment of the URL is the controller, the second segment is the method, and the rest are the parameters.
The show() method is called if the method part of the URL is an ID. (e.g. /products/1)
Example:
URL: /products/1
Controller: ProductsController
Method: show
Parameters: [1]
// app/Controllers/ProductsController.php
class ProductsController extends Controller
{
public function index()
{
$products = Product::getAll();
// ...
}
public function show($id)
{
$product = Product::find($id);
// ...
}
}
Models are stored in the app/Models directory. Each model should extend the Core\Model class.
Common constructor methods used are:
__construct($data = []): Create a new instance of the model with the given datapublic function loadChildren()
{
$sql = 'SELECT * FROM _Children WHERE _parentID = :parentID ORDER BY _index ASC';
$stmt = self::db()->query($sql, ['parentId' => $this->id]);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($results as $row)
{
$childID = (int)$row['_childID'];
$child = new Child($row);
$this->children[$childID] = $child;
}
}
Common static methods used are:
getAll(): Get all records - return an array of modelsfind($id): Find a record by ID - return a single modelCommon instance methods used are:
save(): Save the record - return $thisdelete(): Delete the record - return booleantoArray(): Convert the record to an array (usually an associative array) - return arrayThe database connection is stored in the Core\Database class. You can access the connection using the static db() method in your models.
$sql = 'SELECT * FROM _Children WHERE _parentID = :parentID ORDER BY _index ASC';
$stmt = self::db()->query($sql, ['parentId' => $this->id]);
Views are stored in the app/Views directory. Each view should be a .php file.
Partial views are stored in the app/Views/***/partials directory.
Views can be rendered using the methods render(), renderPartial(), and renderAuto() in the Core\View class.
There is also renderComponent() for rendering components.
Use $this->view->render() or one of the methods below in the controller to render a view.
render() to render a full view with the layout.renderPartial() to render a view without the layout.renderAuto() to automatically render a view with or without the layout based on if the request is a Htmx request. This is usually used when wanting to render a full view with the layout for a normal request and render a partial view without the layout for an Htmx request.renderComponent() to render a component view. This is usually used when rendering a component view in another view. Like navbar, footer, etc.Don't forget to pass data to the view using $this->view->set($key, $value) in the controller.
Also set the title using $this->view->setTitle($title) in the controller.
class ProductsController extends Controller
{
public function index()
{
$this->view->setTitle('Products');
$products = Product::getAll();
$productsDTO = ProductDTO::createFromCollection($products);
$this->view->set('products', $productsDTO);
return $this->view->renderAuto();
}
public function show($id)
{
$product = Product::find($id);
$this->view->set('productName', $product->name);
$this->view->set('productID', $product->id);
$this->view->set('productPrice', $product->price);
$this->view->setTitle('Product: ' . $product->name);
// hx-target="#product-detail" is used in the main view, so we render a partial view
if (Request::isHtmx())
{
$this->view->renderPartial('products/partials/product_detail');
// Or if you want to be specific:
if (HtmxRequest::getTarget() == 'product-detail')
{
// $this->view->renderPartial('products/partials/product_detail');
}
}
}
}
Data Transfer Objects (DTOs) are stored in the app/DTOs directory. Each DTO should extend the Core\DTO class.
DTOs are used to transform data from one format to another. Or when you want to pass data more securely.
The fill() method in the Core\DTO class is used to fill the DTO object with data.
If the model has a toArray() method, you can use it to fill the DTO object.
The Core\DTO also has methods for converting the data to an array or JSON.
$dto->toArray() returns an associative array.$dto->toJson() returns a JSON string.Example:
class ProductDTO extends DTO
{
public $id;
public $name;
public $price;
/**
* Create a ProductDTO object from a Product model.
*
* @param Product $product The Product model to convert.
* @return ProductDTO The ProductDTO object.
*/
public static function createFromModel(Product $product)
{
$dto = new ProductDTO();
$dto->fill($product->toArray());
return $dto;
}
/**
* Creates an array of ProductDTO objects from a collection of products.
*
* @param Product[] $collection An array of Product objects.
* @return ProductDTO[] An array of ProductDTO objects.
*/
public static function createFromCollection(array $products)
{
$dtos = [];
foreach ($products as $product)
{
$dtos[] = self::createFromModel($product);
}
return $dtos;
}
}
Usage:
$product = Product::find(1);
$productDTO = ProductDTO::createFromModel($product);
Store component views in the app/Views/components directory.
Components are used to render reusable views. They can be rendered using the renderComponent() method in the Core\View class.
Examples of components are navbar, footer, etc.
Toasts are used to display messages to the user. Added in controllers and displayed in the layout view.
// Default
Toast::addDefault('You have ' . count($productsInCart) . ' products in your cart');
// Success
Toast::addSuccess('Product added to cart');
// Error
Toast::addError('Failed to add product to cart');
// Warning
Toast::addWarning('Product is out of stock');
// Info
Toast::addInfo('Product is on sale');
Middleware is used to filter requests before they reach the controller methods.
Add middlewares in the controller constructor. This example adds the RateLimitMiddleware.
Middlewares must be added in the constructor of the controller. Otherwise, the middleware will not be executed.
public function __construct()
{
parent::__construct();
// Limit login attempts to 5 per minute
if (Router::getControllerMethod() == 'auth' && HttpRequest::isPost())
{
$this->addMiddleware(RateLimitMiddleware::class, RateLimitMiddleware::config('auth_attempt', 5, 60));
}
else // Limit all other requests to 120 per minute
{
$this->addMiddleware(RateLimitMiddleware::class, RateLimitMiddleware::config('auth', 120, 60));
}
}
In the example above, the RateLimitMiddleware is added to the controller. You can also use the RateLimiterService directly.
use Adderat\Core\Services\RateLimiterService;
class AuthController extends Controller
{
public function auth()
{
// Limit the number of requests to 5 per minute
RateLimiterService::limit('auth', 5, 60);
}
}
Naming convention for branches:
- feature/feature-name 'implement-search-bar'
- fix/fix-name 'fix-responsive-layout-issue'
- hotfix/hotfix-name 'restore-deleted-files'
- chore/chore-name 'update-dependencies'
- docs/docs-name 'update-readme'
- refactor/refactor-name 'simplify-code-product-controller'
- release/release-name 'v1.0.0'
- perf/perf-name 'reduce-page-load-time'
- style/style-name 'update-color-scheme'
Use BEM (Block Element Modifier) naming convention for CSS classes.
These are some examples of BEM class names (not actual CSS classes in the project):
.block {}
.block__element {}
.block__element--modifier {}
.navigation {}
.navigation__item {}
.navigation__item--active {}
.pricing-table {}
.pricing-table__plan {}
.pricing-table__plan--featured {}
.pricing-table__plan-name {}
.pricing-table__plan-price {}
.pricing-table__plan-price--discounted {}
Each BEM element should be directly related to its block, not nested under another element. Even though elements may appear inside one another in the HTML, their class names should reflect their relationship to the block, not to other elements.
/* Good */
.pricing-table {}
.pricing-table__plan {}
.pricing-table__plan-name {}
/* Bad */
.pricing-table {}
.pricing-table__plan {}
.pricing-table__plan__name {}
Avoid using CSS classes in JavaScript. Instead, use data attributes, ID, or JavaScript hooks (like js-* classes). This keeps separation of concerns between your styles and scripts, ensuring that changes to the CSS won't break JavaScript functionality.
<table class="table pricing-table">
<tr class="pricing-table__plan" data-plan="basic">
<td class="pricing-table__plan-name">Basic Plan</td>
<td class="pricing-table__plan-price">100 SEK/month</td>
<td><button class="btn btn-secondary" data-plan-action="buy-now">Buy Now</button></td>
</tr>
<tr class="pricing-table__plan pricing-table__plan--featured" data-plan="premium">
<td class="pricing-table__plan-name">Premium Plan</td>
<td class="pricing-table__plan-price">100 SEK/month</td>
<td><button class="btn btn-primary" data-plan-action="buy-now">Buy Now</button></td>
</tr>
</table>
const buyNowButtons = document.querySelectorAll('[data-plan-action="buy-now"]');
buyNowButtons.forEach(button => {
button.addEventListener('click', () => {
const plan = button.closest('[data-plan]').dataset.plan;
console.log('Buy now:', plan);
});
});