URLs amigáveis com PHP e OO – PHP sem framework – parte 2

Tempo de leitura 11 minutos

URLs amigáveis é, de fato, um assunto bem simples de entender e que de mágico não tem nada, neste artigo vamos ver como criar um sistema de rotas para servir URLs amigáveis para o nosso site SEM tornar tudo COMPLICADO e “extremamente” dependente do .htaccess.

Não esqueça de se cadastrar na newsletter para saber quando um novo post for lançado.

O que é URL amigável?

As famosas URLs amigáveis já estão no nosso meio a algum tempo, hoje em dia é básico encontramos sites que utilizam esse recurso para facilitar a vida do usuário e, de quebra, ganhar alguns pontos no SEO do site.

Muito disso se deve a frameworks e CMS que já trazem o recurso como base de sua construção, estas são as famosas rotas que dizem o que deve ser carregado no sistema, mas o que é uma URL amigável.

Antes de chegarmos ao ponto vamos voltar alguns anos e lembrar da época em que o tudo era baseado em query strings, quem lembra disso?

https://blog.erikfigueiredo.com.br/?page=post&article=2

Na URL temos a parte ?page=post&article=2, essa é a query string, também conhecido como parâmetros GET (mas vamos chamar de query string), dentro do PHP podemos ler cada item informado usando a supervariável $_GET:

<?php

$page = $_GET['page'];
$article = $_GET['article'];

E com base nisso carregávamos arquivos e consultávamos o banco de dados. O fato é que a URL acima, embora funcione muito bem, não é tão “amigável” quanto esta:

https://blog.erikfigueiredo.com.br/projeto-php-do-zero-vale-a-pena-serie-php-sem-framework

Estas são as URLs amigáveis, ou seja, apenas um parâmetro que passamos na URL que é mais simples para um ser humano ler e MUITO mais aceita por motores de busca.

Uma informação importante é que existem as urls estáticas e as variáveis (ou dinâmicas), sendo que a diferença entre elas é que a variável recebe parâmetros que podem (ou não) alterar o que será exibido.

Por exemplo, uma rota para uma lista de usuários em um sistema é estática:

http://localhost:8000/admin/usuarios

Enquanto que visualizar um cadastro de determinado usuário deverá recebe parâmetros dinâmicos para identificar este usuário:

http://localhost:8000/admin/usuarios/{idDoUsuario}

Como o PHP lê as URLs amigáveis?

No exemplo com query string os parâmetros seriam enviados para um arquivo PHP (provavelmente index.php) e lidos através do $_GET ou filter_input (o segundo é mais eficiente, #ficaADica).

No exemplo anterior, eu não informei um index.php, mas ele está lá, mesmo que voce não veja.

// Ambas as urls apontam para o index.php

https://blog.erikfigueiredo.com.br/index.php?page=post&article=2
https://blog.erikfigueiredo.com.br/?page=post&article=2

No caso das URLs amigáveis, isso não é tão diferente assim, só que o servidor web (Apache, Nginx, PHP Built-In Server…) é configurado para lidar com a ausência do arquivo PHP na URL, mesmo que você não escreva, o servidor web “finge” que ele está lá (se você configurar).

// Ambas as urls apontam para o index.php, mas a segunda precisa do servidor web
// configurado corretamente

http://localhost:8000/index.php/ola-mundo
http://localhost:8000/ola-mundo

Para ficar mais claro, aqui tem dois links de documentações de frameworks conceituados que mostram como configurar o Apache e Nginx para URLs amigáveis no formato que vamos construir.

https://laravel.com/docs/6.x#pretty-urls

http://www.slimframework.com/docs/v4/start/web-servers.html

Da um nó na cabeça, eu sei, mas isso porque costumamos trabalhar com essa estrutura imaginando diretórios no servidor, mas esse não é o caso, o /ola-mundo é um parâmetro do script index.php e podemos ler isso assim:

<?php

$route = $_SERVER['PATH_INFO'] ?? '/';
var_dump($route);

Note que eu usei o operador ??, ele quer dizer que se o primeiro valor não for encontrado ou for nulo devemos usar o segundo, ou seja, a barra. Eu fiz isso porque quando uma URL amigável não for informada, o item PATH_INFO não existe na supervariável $_SERVER.

Outra coisa, não vamos mais chamar de url amigável, vamos usar o nome PATH a partir de agora.

Agora que sabemos como isso tudo funciona, vamos construir um sistema de rotas amigáveis usando PHP e que vai servir para QUALQUER projeto PHP que você queira construir.

Entendendo nosso sistema de URLs amigáveis

Para este exemplo ficar simples, vou usar alguns conceitos que já falei em outro artigo, o primeiro desta série, você não precisa ler (embora eu recomende), apenas baixe o esqueleto base que você vai conseguir seguir em frente sem problemas.

Para ler o artigo e saber como iniciar um projeto PHP do zero, clique aqui.

Para baixar o esqueleto criado no artigo anterior, clique aqui.

Agora que já temos o esqueleto do projeto, note que no diretório src tem um arquivo chamado Router.php e no diretório tests temos um RouterTest.php, o primeiro arquivo é a nossa feature, nele vamos construir um sistema de rotas para servir URLs amigáveis no nosso projeto, já o segundo existirá exclusivamente para testar o que construirmos, quero dizer, vamos usar testes automatizados. Você vai descobrir que é muito mais prático que testar no navegador.

Em primeiro lugar eu vou definir o que o Router vai fazer:

  • Encontrar a rota com base na url e no método HTTP (GET, POST…)
  • Retornar uma ação a ser executada (uma function) quando encontrar a rota pela URL
  • Retornar false quando não encontrar a rota pela URL e método HTTP

É bem simples, eu vou informar a url que foi acessada pelo usuário, uma lista de rotas configuradas com o que fazer quando forem acessadas e quando eu executar o Router, ele deve me dizer qual das rotas listadas será executada.

Parece complicado quando eu explico, mas é bem simples, veja só:


/**
 * Aqui eu inicio o router
 * eu passo qual método HTTP foi usado na requisição
 * e qual o path acessado
 */
$router = new Router('GET', '/users');

// Cadastro a primeira ação do projeto
$router->add('GET', '/usuarios', function () {
    /**
     * Aqui eu retorno um "HTML" para ser renderizado
     * posso buscar dados no banco e usar templates engine
     *
     * Esta rota listará usuários cadastrados
     */
});

// Cadastro a segunda ação do projeto
$router->add('GET', '/usuarios/novo', function () {
    /**
     * Esta rota mostrará um formulário para cadastrar
     * novos usuários
     */
});

// Cadastro a terceira ação do projeto
$router->add('POST', '/usuarios/novo', function () {
    /**
     * Esta rota será executada quando enviar o formulário
     * Ela salva usuários no banco de dados
     */
});

/**
 * Executo o método do router que irá verificar 
 * qual é a rota acessada
 */
$result = $router->handler();

// Se uma página for encontrada
if ($result) {
    // eu imprimo o valor na tela
    echo $result();
} else {
    // se não, digo que a página não foi encontrada
    echo 'Página não encontrada';
}

Agora ficou mais claro? Obviamente este é o resultado final, vamos construir nosso motor de rotas.

Quando você sabe o que será feito e o que EXATAMENTE você quer, tudo fica muito mais fácil, usar testes automatizados nos ajuda nesse ponto, altere o RouterTest para:

<?php

namespace ErikFig\Framework;

use PHPUnit\Framework\TestCase;

class RouterTest extends TestCase
{
    public function testVerificaSeEncontraRota()
    {
        // Verifica se encontra rota
    }

    public function testVerificaNaoSeEncontraRota()
    {
        // 
    }

    public function testVerificaNaoSeEncontraRotaComMetodoErrado()
    {
        // 
    }

    public function testVerificaSeEncontraRotaVariavel()
    {
        // 
    }
}

Note que eu só comentei o que o primeiro método deve testar, é basicamente o que já diz o próprio nome deste mesmo método, ler os demais é por sua conta, entender e escrever nomes de métodos é MUITO importante para organização de testes.

Agora que já temos TODAS as informações sobre o nosso motor de rotas, vamos construí-lo juntamente com seus testes.

Construindo um sistema de rotas

Pronto, a parte mais esperada, contruir o nosso motor de rotas.

Uma coisa que não disse até agora é que eu QUERO que nossas rotas sejam agrupadas por método HTTP, ou seja, eu quero poder separar as rotas em grupos para GET, POST, PUT, DELETE e outros métodos HTTP diversos, assim eu vou poder (por exemplo) enviar um formulário para a mesma url, porém com ação diferente (duas urls iguais, uma em um grupo GET e outra em um grupo POST) ou talvez até criar uma API.

Eu ainda não escrevi nada sobre isso de verbos HTTP, mas é bem simples, toda requisição web usa um dos métodos disponíveis, os mais conhecidos são GET e POST. A nevegação comum (digitar uma url na barra de endereços do navegador) usa o GET, o POST é indicado para o envio de formulários (mas não se limita a isso), existem muitos usos para os métodos (ou verbos) HTTP, assim como existem muitos outros métodos, como PATCH e OPTIONS.

Adendo ao artigo

Para seguirmos as boas práticas do TDD e você endender aonde eu quero chegar, vamos alterar o RouterTest, incluindo no método testVerificaSeEncontraRota o seguinte (eu comentei o código para facilitar).

public function testVerificaSeEncontraRota()
    {
        // instancio o Router informando o método HTTP e a url digitada
        // estes são os dados que queremos encontrar
        $router = new Router('GET', '/ola-mundo');

        // registro minha primeira rota, esta rota só retorna um
        // true como exemplo, ela poderia fazer qualquer outra coisa
        $router->add('GET', '/ola-mundo', function () {
            return true;
        });

        // executamos o método que encontra a rota atual
        $result = $router->handler();

        // executo a ação da rota encontrada e pego o valor,
        // note que estou executando o método que
        // registrei quando usei o $router->add
        $actual = $result();

        // o valor que espero que seja retornado pelo $actual
        $expected = true;

        // verifico se o valor atual é o mesmo do esperado
        $this->assertEquals($expected, $actual);
    }

Agora vamos a classe que fará essa mágica acontecer:

<?php

namespace ErikFig\Framework;

class Router
{
    // essa variávei vai guardar as rotas registradas
    private $routes = [];
    // o método HTTP atual
    private $method;
    // a url atual
    private $path;

    // injeto o método HTTP e a url atual na minha classe
    public function __construct($method, $path)
    {
        $this->method = $method;
        $this->path = $path;
    }

    // para facilitar o uso eu criei esse método
    // atalho para o add
    public function get(string $route, callable $action)
    {
        $this->add('GET', $route, $action);
    }

    // para facilitar o uso eu criei esse método
    // atualho para o add
    public function post(string $route, callable $action)
    {
        $this->add('POST', $route, $action);
    }

    /* 
        este método é o que registra as rotas, eu crio um array com dois níveis
        o primeiro com o método HTTP o segundo com a url registrada
        o valor é a ação a ser executada

        Note que eu eu passei o método e a url da rota diretamente na chave
        do array, isso vai evitar duplicidade.

        Como exercício, você pode adicionar uma verificação para ver se a rota
        já foi registrada ANTES.
    */
    public function add(string $method, string $route, callable $action)
    {
        $this->routes[$method][$route] = $action;
    }

    // encontra a rota
    public function handler()
    {
        // verifica se o método HTTP foi registrado
        if (empty($this->routes[$this->method])) {
            // se NÃO encontrar, retorna false
            return false;
        }

        // verifica se a url foi registrada para ESTE método
        if (isset($this->routes[$this->method][$this->path])) {
            // SE FOI registrada, retorna a ação (o valor do array)
            return $this->routes[$this->method][$this->path];
        }

        // se não achar a rota, retorna false
        return false;
    }
}

Nossa que classe grande, calma, são muitos comentários também, aqui o resultado sem comentários:

<?php

namespace ErikFig\Framework;

class Router
{
    private $routes = [];
    private $method;
    private $path;

    public function __construct($method, $path)
    {
        $this->method = $method;
        $this->path = $path;
    }    
    public function get(string $route, callable $action)
    {
        $this->add('GET', $route, $action);
    }

    public function post(string $route, callable $action)
    {
        $this->add('POST', $route, $action);
    }

    public function add(string $method, string $route, callable $action)
    {
        $this->routes[$method][$route] = $action;
    }

    public function handler()
    {
        if (empty($this->routes[$this->method])) {
            return false;
        }

        if (isset($this->routes[$this->method][$this->path])) {
            return $this->routes[$this->method][$this->path];
        }

        return false;
    }
}

A classe é bem simples e está bem comentada, assim você sabe exatamente o que cada linha faz.

Note que já temos o segundo teste resolvido também:

    public function testVerificaNaoSeEncontraRota()
    {
        $router = new Router('GET', '/outra-url'); // esta rota não foi registrada

        // esta rota não é a que está sendo usada
        $router->add('GET', '/ola-mundo', function () {
            return true;
        });

        $result = $router->handler();

        $actual = $result;
        $expected = false;

        // estou usando o assertNotEquals
        // que verifica se os valores são diferentes
        // antes eu usei o assertEquals
        // que verifica se eles são iguais
        $this->assertNotEquals($expected, $actual);
    }

Viu como a coisa está andando, as rotas variáveis são um pouco mais complicadas, nesse passo vamos precisar de um pouco de expressões regulares. Em outra oportunidade quero falar um pouco sobre as expressões regulares.

    public function testVerificaSeEncontraRotaVariavel()
    {
        $router = new Router('GET', '/ola-erik');
        $router->add('GET', '/ola-{nome}', function () {
            return true;
        });

        $result = $router->handler();

        $actual = $result();
        $expected = true;
        $this->assertEquals($expected, $actual);
    }

A rota que queremos encontrar agora é a /ola-{nome}, a parte {nome} é a parte variável, eu quero poder escolher o que vou escrever ali, escrevendo o nome que eu quiser e a rota continuar funcionando.

Minha proposta para isso:


    // este método recebe dois parâmetros, $route e $path
    // $route é a rota registrada que vamos testar
    // $path e a url amigável que quero procurar
    private function checkUrl(string $route, $path)
    {
        // aqui eu pego tudo o que tiver entre {} (chaves)
        // na rota e armazeno na variável $variables, por exemplo:
        // a rota "/ola-{nome}" (na variáve $route)
        // vai retornar "nome" dentro do array $variables
        preg_match_all('/\{([^\}]*)\}/', $route, $variables);

        // Eu preciso transformar a rota em expressão regular
        // mas o caracter / pode ter significado.
        // Eu estou trocando todos para \/, isso vai dizer
        // para a expressão regular que o / significa / e não
        // adiciona nenhuma regra especial.
        $regex = str_replace('/', '\/', $route);

        // agora eu vou pegar TODAS as variáves entre {} em
        // um valor de regex (expressão regular) real
        // eu quero que entre os possíveis caracteres estejam
        // letras entre a e z (maiúsculas e minúsculas)
        // números de 0 a 9, híphen, underline e espaços
        // qualquer outro caracter não será encontrado
        foreach ($variables[0] as $k => $variable) {
            $replacement = '([a-zA-Z0-9\-\_\ ]+)';
            $regex = str_replace($variable, $replacement, $regex);
        }
        // removo todas as chaves ({})
        $regex = preg_replace('/{([a-zA-Z]+)}/', '([a-zA-Z0-9+])', $regex);
        // executo (finalmente) as expressões regulares
        $result = preg_match('/^' . $regex . '$/', $path);

        // O $result retorna a quantidade de valores encontrados
        return $result;
    }

Eu também vou precisar alterar o handler da classe Router:

    public function handler()
    {
        if (empty($this->routes[$this->method])) {
            return false;
        }

        if (isset($this->routes[$this->method][$this->path])) {
            return $this->routes[$this->method][$this->path];
        }

        // Pego TODAS as rotas dentro do método http informado
        foreach ($this->routes[$this->method] as $route => $action) {
            // e testo cada uma
            $result = $this->checkUrl($route, $this->path);
            // se encontrar um resultado
            if ($result >= 1) {
                // retorno a $action (assim como no if anterior)
                return $action;
            }
        }

        // se não achar nada, retorno false
        return false;
    }

Uma coisa legal é que no momento que um return executa dentro de um loop, o loop é interrompido, isso garante que ele não vá gastar memória rodando ações desnecessárias.

Prontinho, nossa classe está “semi-pronta”, ainda tem mais uma feature que quero incluir, mas por hora, vamos testar em um ambiente mais “real”.

Usando a biblioteca de URLs amigáveis no navegador

Para testar eu vou usar uma estratégia que vai impedir o usuário de navegadores de ter acesso aos arquivos do projeto, eu vou apontar o documento root para um subdiretório, bloqueando o acesso ao diretório raiz.

Crie um diretório chamado public na raiz do projeto e dentro dele um arquivo index.php com o seguinte conteúdo:

<?php

include __DIR__ . '/../bootstrap.php';

E atualize o bootstrap.php para:

<?php

require __DIR__ . '/vendor/autoload.php';

// defino o método http e a url amigável
$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['PATH_INFO'] ?? '/';

// instancio o Router
$router = new ErikFig\Framework\Router($method, $path);

// registro as rotas
$router->get('/', function () {
    return 'Olá mundo';
});

$router->get('/ola-{nome}', function () {
    return 'Olá mundo 2';
});

// faço o router encontrar a rota que o usuário acessou
$result = $router->handler();

// se retornar false, dou um erro 404 de página não encontrada
if (!$result) {
    http_response_code(404);
    echo 'Página não encontrada!';
    die();
}

// imprimo a página atual
echo $result();

Agora basta rodar um servidor web no diretório public, eu usei o PHP Built-In Server a partir da raiz do projeto:

php -S localhost:8000 -t public/

No navegador, acesse o endereço do host (no meu caso, http://localhost:8000) seguido de /index.php (nosso script).

Router executou a rota 1

Ele encontrou a primeira rota, teste com http://localhost:8000/index.php/

Agora teste a segunda rota, http://localhost:8000/index.php/ola-erik

Segunda rota

Tente trocar o -erik para -seu-nome ou -mundo

Usando os parâmetros variáveis das URLs amigáveis

Por último e não menos importando, que tal pegarmos os valores variáveis da url para usarmos nos nossos scripts? Parece difícil, mas não é, é só “ler” os valores dinâmicos e armazenar na classe.

A alteração está no método checkUrl

<?php

namespace ErikFig\Framework;

class Router
{
    private $routes = [];
    private $method;
    private $path;
    private $params; // uma variável nova para armazenar os valores

    public function __construct($method, $path)
    {
        $this->method = $method;
        $this->path = $path;
    }

    public function get(string $route, callable $action)
    {
        $this->add('GET', $route, $action);
    }

    public function post(string $route, callable $action)
    {
        $this->add('POST', $route, $action);
    }

    public function add(string $method, string $route, callable $action)
    {
        $this->routes[$method][$route] = $action;
    }

    // um método para retornar os parâmetros sem permitir
    // que sejam alterados fora da classe
    public function getParams()
    {
        return $this->params;
    }

    public function handler()
    {
        if (empty($this->routes[$this->method])) {
            return false;
        }

        if (isset($this->routes[$this->method][$this->path])) {
            return $this->routes[$this->method][$this->path];
        }

        foreach ($this->routes[$this->method] as $route => $action) {
            $result = $this->checkUrl($route, $this->path);
            if ($result >= 1) {
                return $action;
            }
        }

        return false;
    }

    private function checkUrl(string $route, $path)
    {
        preg_match_all('/\{([^\}]*)\}/', $route, $variables);

        $regex = str_replace('/', '\/', $route);

        foreach ($variables[0] as $k => $variable) {
            $replacement = '([a-zA-Z0-9\-\_\ ]+)';
            $regex = str_replace($variable, $replacement, $regex);
        }

        $regex = preg_replace('/{([a-zA-Z]+)}/', '([a-zA-Z0-9+])', $regex);

        // adicionei a variável $params para "guardar" os parâmetros variáveis
        // que a regex encontrar
        $result = preg_match('/^' . $regex . '$/', $path, $params);
        // guardo na variável params da classe
        $this->params = $params;

        return $result;
    }
}

E altero nosso bootstrap.php em dois lugares, no registro da rota:

$router->get('/ola-{nome}', function ($params) {
    return 'Olá ' . $params[1]; // o parametro 0 é a rota toda
});

E na última linha:

// imprimo a página atual
echo $result($router->getParams());

Pronto, agora temos um sistema de rotas amigáveis que permite criar sistemas de pequeno, médio e grande porte, totalmente orientado a objetos e seguindo padrões modernos de desenvolvimento.

Parabéns pra gente!

O resultado final:
https://github.com/erikfig/php-do-zero/tree/63ff2631ad1a5ec5ceb503e805df102b7e3d09b9

Mais artigos desta série

Artigo anterior

Próximo artigo

Todos os artigos

Autor: Erik Figueiredo

Músico, gamer amador, tutor de programação, desenvolvedor freelancer full cycle, com foco em PHP (Laravel e CakePHP), Javascript (Front e Node.js), Dart (Front e Flutter) e infra.

3 comentários em “URLs amigáveis com PHP e OO – PHP sem framework – parte 2”

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *