Critério:
Cada ano divisível por 4 é um ano bissexto, exceto quando é divisível por 100, a menos que seja divisível por 400. Então:
2004 - leap year - divisible by 4
1900 - not a leap year - divisible by 4, but also divisible by 100
2000 - leap year - divisible by 4, also divisible by 100, but divisible by 400
Fevereiro tem 29 dias em um ano bissexto e 28 dias quando não é um ano bissexto
30 dias em abril, junho, setembro e novembro
31 dias em janeiro, março, maio, julho, agosto, outubro e dezembro
Teste:
As seguintes datas devem passar na validação:
1976-02-29
2000-02-29
2004-02-29
1999-01-31
As seguintes datas devem falhar na validação:
2015-02-29
2015-04-31
1900-02-29
1999-01-32
2015-02-00
Alcance:
Vamos testar as datas de 1º de janeiro de 1000 a 31 de dezembro de 2999. Tecnicamente, o calendário gregoriano usado atualmente só entrou em uso em 1753 para o Império Britânico e em vários anos de 1600 para países da Europa, mas não vou se preocupe com isso.
Regex para testar em um ano bissexto:
Os anos divisíveis por 400:
1200|1600|2000|2400|2800
can be shortened to:
(1[26]|2[048])00
if you wanted all years from 1AD to 9999 then this would do it:
(0[48]|[13579][26]|[2468][048])00
if you're happy with accepting 0000 as a valid year then it can be shortened:
([13579][26]|[02468][048])00
Os anos são divisíveis por 4:
[12]\d([02468][048]|[13579][26])
Os anos divisíveis por 100:
[12]\d00
Não divisível por 100:
[12]\d([1-9]\d|\d[1-9])
Os anos divisíveis por 100, mas não por 400:
((1[1345789])|(2[1235679]))00
Divisível por 4, mas não por 100:
[12]\d([2468][048]|[13579][26]|0[48])
Os anos bissextos:
divisible by 400 or (divisible by 4 and not divisible by 100)
((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48])
Não divisível por 4:
[12]\d([02468][1235679]|[13579][01345789])
Não é um ano bissexto:
Not divisible by 4 OR is divisible by 100 but not by 400
([12]\d([02468][1235679]|[13579][01345789]))|(((1[1345789])|(2[1235679]))00)
Mês e dia válidos, exceto fevereiro (MM-DD):
((01|03|05|07|08|10|12)-(0[1-9]|[12]\d|3[01]))|((04|06|09|11)-(0[1-9]|[12]\d|30))
shortened to:
((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30))
Fevereiro com 28 dias:
02-(0[1-9]|1\d|2[0-8])
Fevereiro com 29 dias:
02-(0[1-9]|[12]\d)
Data válida:
(leap year followed by (valid month-day-excluding-february OR 29-day-february))
OR
(non leap year followed by (valid month-day-excluding-february OR 28-day-february))
((((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48]))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|[12]\d))))|((([12]\d([02468][1235679]|[13579][01345789]))|((1[1345789]|2[1235679])00))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|1\d|2[0-8]))))
Então, você tem uma regex para datas entre 1º de janeiro de 1000 e 31 de dezembro de 2999 no formato AAAA-MM-DD.
Suspeito que pode ser um pouco abreviado, mas deixarei isso para outra pessoa.
Isso corresponderá a todas as datas válidas. Se você quiser que seja válido apenas quando contiver apenas uma data e nada mais, envolva-o ^( )$
assim:
^(((((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48]))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|[12]\d))))|((([12]\d([02468][1235679]|[13579][01345789]))|((1[1345789]|2[1235679])00))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|1\d|2[0-8])))))$
Se você quiser uma entrada de data opcional (ou seja, pode estar em branco ou uma data válida), adicione ^$|
no início, assim:
^$|^(((((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48]))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|[12]\d))))|((([12]\d([02468][1235679]|[13579][01345789]))|((1[1345789]|2[1235679])00))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|1\d|2[0-8])))))$
date("Y-m-d", strtotime("2012-09-12"))=="2012-09-12";
ou PHPcheckdate ( int $month , int $day , int $year )
.