I've been using the accepted answer's code (Felipe's code) for a while and it's been working great (thanks, Felipe!).
However, recently I discovered that it has issues with empty objects or arrays.
For example, when submitting this object:
{
    A: 1,
    B: {
        a: [ ],
    },
    C: [ ],
    D: "2"
}
PHP doesn't seem to see B and C at all. It gets this:
[
    "A" => "1",
    "B" => "2"
]
A look at the actual request in Chrome shows this:
A: 1
:
D: 2
I wrote an alternative code snippet. It seems to work well with my use-cases but I haven't tested it extensively so use with caution.
I used TypeScript because I like strong typing but it would be easy to convert to pure JS:
angular.module("MyModule").config([ "$httpProvider", function($httpProvider: ng.IHttpProvider) {
    // Use x-www-form-urlencoded Content-Type
    $httpProvider.defaults.headers.post["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8";
    function phpize(obj: Object | any[], depth: number = 1): string[] {
        var arr: string[] = [ ];
        angular.forEach(obj, (value: any, key: string) => {
            if (angular.isObject(value) || angular.isArray(value)) {
                var arrInner: string[] = phpize(value, depth + 1);
                var tmpKey: string;
                var encodedKey = encodeURIComponent(key);
                if (depth == 1) tmpKey = encodedKey;
                else tmpKey = `[${encodedKey}]`;
                if (arrInner.length == 0) {
                    arr.push(`${tmpKey}=`);
                }
                else {
                    arr = arr.concat(arrInner.map(inner => `${tmpKey}${inner}`));
                }
            }
            else {
                var encodedKey = encodeURIComponent(key);
                var encodedValue;
                if (angular.isUndefined(value) || value === null) encodedValue = "";
                else encodedValue = encodeURIComponent(value);
                if (depth == 1) {
                    arr.push(`${encodedKey}=${encodedValue}`);
                }
                else {
                    arr.push(`[${encodedKey}]=${encodedValue}`);
                }
            }
        });
        return arr;
    }
    // Override $http service's default transformRequest
    (<any>$httpProvider.defaults).transformRequest = [ function(data: any) {
        if (!angular.isObject(data) || data.toString() == "[object File]") return data;
        return phpize(data).join("&");
    } ];
} ]);
It's less efficient than Felipe's code but I don't think it matters much since it should be immediate compared to the overall overhead of the HTTP request itself.
Now PHP shows:
[
    "A" => "1",
    "B" => [
        "a" => ""
    ],
    "C" => "",
    "D" => "2"
]
As far as I know it's not possible to get PHP to recognize that B.a and C are empty arrays, but at least the keys appear, which is important when there's code that relies on the a certain structure even when its essentially empty inside.
Also note that it converts undefineds and nulls to empty strings.