User login
Authentication is checking who is in front of us. Slim has no ready-made login, but it is assembled from familiar parts: a users table, a form, the session and a middleware that keeps guests away from protected pages.
The users table
Add this to database/schema.sql and run it in the database:
-- database/schema.sql
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL
);The database stores not the password but its hash — an irreversibly transformed string. Create the first user with a small script in a new bin folder:
// bin/create-user.php
$app = require __DIR__ . '/../config/bootstrap.php';
$pdo = $app->getContainer()->get(PDO::class);
$statement = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$statement->execute([$argv[1], password_hash($argv[2], PASSWORD_DEFAULT)]);
echo "User created\n";php bin/create-user.php anna@example.com secret-passwordThe script loads the same application, takes PDO from the container and inserts a row with the password hash.
Finding a user
// src/Repository/UserRepository.php
namespace App\Repository;
use PDO;
final class UserRepository
{
public function __construct(private PDO $pdo) {}
public function findByEmail(string $email): ?array
{
$statement = $this->pdo->prepare('SELECT * FROM users WHERE email = ?');
$statement->execute([$email]);
return $statement->fetch() ?: null;
}
}The login action
Create the login form — the templates/auth/login.twig template with email and password fields and the LoginFormAction action — the same way as the post form. A second action receives the data:
// src/Action/LoginAction.php
final class LoginAction
{
public function __construct(private UserRepository $users, private Twig $twig) {}
public function __invoke(Request $request, Response $response): Response
{
$data = (array) $request->getParsedBody();
$user = $this->users->findByEmail(trim($data['email'] ?? ''));
if ($user === null || !password_verify($data['password'] ?? '', $user['password_hash'])) {
return $this->twig->render($response->withStatus(422), 'auth/login.twig', [
'error' => 'Wrong email or password',
]);
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
return $response->withHeader('Location', '/posts')->withStatus(302);
}
}password_verify() compares the entered password with the hash. On success the user's number is written to the session — that is the "login". session_regenerate_id() gives the session a new identifier so the old one cannot be reused.
The access middleware
// src/Middleware/AuthMiddleware.php
final class AuthMiddleware implements MiddlewareInterface
{
public function __construct(private ResponseFactoryInterface $responseFactory) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (empty($_SESSION['user_id'])) {
return $this->responseFactory->createResponse(302)->withHeader('Location', '/login');
}
return $handler->handle($request->withAttribute('user_id', $_SESSION['user_id']));
}
}For a guest the middleware returns a redirect itself — the action does not run. The response object is created by the ResponseFactoryInterface factory defined in the container.
Routes
// config/routes.php
$app->get('/login', LoginFormAction::class)->setName('login');
$app->post('/login', LoginAction::class);
$app->group('', function (RouteCollectorProxy $group) {
$group->get('/posts/new', NewPostAction::class)->setName('posts.new');
$group->post('/posts', CreatePostAction::class)->setName('posts.store');
})->add(AuthMiddleware::class);A group with an empty prefix exists only to attach the middleware to several routes. Open /posts/new without logging in — the browser goes to /login. After login the form opens.
Logout
The logout action clears the session and redirects to the home page:
// src/Action/LogoutAction.php
$_SESSION = [];
session_destroy();
return $response->withHeader('Location', '/')->withStatus(302);The logout route uses the POST method so that someone else's link cannot log the visitor out.
Common mistake
Separate messages "no such email" and "wrong password" tell an outsider which addresses are registered. Show one shared message for both cases. And never compare passwords with == or store them in plain text — only password_hash() and password_verify().